@yawlabs/caddy-mcp 2.3.2 → 2.4.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
@@ -3,6 +3,7 @@
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
+ [![Follow @TokenLimitNews on X](https://img.shields.io/badge/follow-%40TokenLimitNews-000000?logo=x&logoColor=white)](https://x.com/TokenLimitNews)
6
7
 
7
8
  **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.
8
9
 
package/bin/caddy-mcp.mjs CHANGED
@@ -32,11 +32,25 @@
32
32
  * `CADDY_MCP_SANDBOX=1` runs the server under oam's permission model.
33
33
  *
34
34
  * The admin API endpoint is DERIVED from CADDY_ADMIN_URL (default
35
- * http://127.0.0.1:2019), host and port both pinned -- grants are prefix-matched,
36
- * so a bare host would also admit every other port on it. Filesystem AND
37
- * child-process both stay denied: this server drives Caddy entirely over its
38
- * admin HTTP API and never shells out to the `caddy` binary (the only
39
- * execFileSync calls in the repo are in src/tests/).
35
+ * http://localhost:2019 -- byte-identical to DEFAULT_URL in src/api.ts, see
36
+ * sandboxFlags). For a TCP endpoint the grant is the HOST, deliberately WITHOUT
37
+ * a port: oam checks `fetch` against the bare hostname and sockets against
38
+ * "host:port", and grants are prefix-matched, so pinning the port denies every
39
+ * fetch -- and fetch is the transport api.ts uses for everything but a unix
40
+ * socket. Granting the host therefore also admits its other ports; that is the
41
+ * cost of the check having no port to match against.
42
+ *
43
+ * A unix-socket CADDY_ADMIN_URL gets NO net grant, which DENIES the category
44
+ * outright -- it is not an oversight that it looks narrower than the TCP case.
45
+ * oam ships no unix socket transport, so the socket dial cannot work under the
46
+ * sandbox regardless; the alternative was a bare `--allow-net`, which grants
47
+ * every host on the network. See sandboxFlags for the mechanism.
48
+ *
49
+ * Child-process stays denied: this server drives Caddy entirely over its admin
50
+ * HTTP API and never shells out to the `caddy` binary (the only execFileSync
51
+ * calls in the repo are in src/tests/). Filesystem stays denied too, EXCEPT when
52
+ * CADDY_MCP_SNAPSHOT_DIR is set: snapshot persistence is the one feature that
53
+ * touches disk, so that directory -- and nothing else -- is granted read+write.
40
54
  *
41
55
  * Opt-in, not default: a denied environment variable is ABSENT from process.env
42
56
  * rather than throwing, so an under-granted CADDY_API_TOKEN reads as
@@ -111,6 +125,14 @@ function findOam() {
111
125
  // the full PATHEXT list would hand back a path this launcher cannot execute.
112
126
  // Discovery has to agree with execution. A skipped shim is still reported --
113
127
  // see findOamShim.
128
+ //
129
+ // scripts/runtime.mjs carries its OWN findOam that DOES walk the full PATHEXT.
130
+ // That is a deliberate difference, not drift: it probes each candidate with
131
+ // execFileSync before returning it, so a shim it cannot run is dropped anyway
132
+ // and a wider walk only ever finds more. This one is stat-only on the hot
133
+ // launch path -- no probe to filter with -- so whatever it returns it must be
134
+ // able to spawn. Same question, different constraint; change one and re-read
135
+ // the other.
114
136
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
115
137
  if (!dir) continue;
116
138
  const candidate = join(dir, exe);
@@ -164,25 +186,118 @@ function sandboxFlags() {
164
186
  if (process.env.CADDY_MCP_SANDBOX !== "1") return [];
165
187
 
166
188
  // Derived, not hardcoded: the only endpoint this server may reach is the one
167
- // it was configured to reach. Grants are prefix-matched against "host:port"
168
- // for sockets, so host alone would also admit any other port on that host --
169
- // pin both. A DSN we cannot parse falls back to a bare grant rather than a
170
- // broken one, because a wrong narrow grant fails at connect time.
171
- const dsn = process.env.CADDY_ADMIN_URL ?? "http://127.0.0.1:2019";
172
- let netFlag = "--allow-net";
173
- if (dsn) {
189
+ // it was configured to reach. A DSN we cannot parse falls back to a bare grant
190
+ // rather than a broken one, because a wrong narrow grant fails at connect time.
191
+ //
192
+ // The default MUST stay byte-identical to DEFAULT_URL in src/api.ts. The grant
193
+ // and the dial are matched as TEXT, so "127.0.0.1" here against an api.ts that
194
+ // dials "localhost" denies every request while both files look right on their
195
+ // own. Change one, change the other.
196
+ //
197
+ // Empty and whitespace-only are treated as UNSET, which is what api.ts does:
198
+ // it reads the variable with `||`, so "" already falls through to DEFAULT_URL
199
+ // there. `??` would keep "" here, skip the parse, and leave the bare
200
+ // `--allow-net` below -- a wide-open sandbox produced by a shell exporting an
201
+ // empty variable, which is a shape shells produce easily.
202
+ const dsn = process.env.CADDY_ADMIN_URL?.trim() || "http://localhost:2019";
203
+
204
+ // A unix-socket admin endpoint gets NO net grant at all -- checked before the
205
+ // URL parse, because it is the one input that would otherwise produce the
206
+ // WIDEST grant instead of the narrowest.
207
+ //
208
+ // `new URL("unix:///run/caddy.sock").hostname` is "", so the `if (u.hostname)`
209
+ // below is false and netFlag would stay the bare `--allow-net`; Caddy's own
210
+ // spelling (`unix//run/caddy.sock`) throws ERR_INVALID_URL and reaches the
211
+ // catch for the same result. Either way the most hardened admin config --
212
+ // Caddy recommends the socket precisely because filesystem permissions beat a
213
+ // loopback port -- would switch the sandbox on and hand over the whole network.
214
+ // That is the same wide-open-by-accident shape the empty-string case above
215
+ // guards against, reached by a different route.
216
+ //
217
+ // Omitting the flag DENIES the category (oam reads an absent --allow-net as
218
+ // false, a bare one as "*"), and denial costs nothing here: oam has no unix
219
+ // socket transport at all, so api.ts's node:http `socketPath` dial cannot work
220
+ // under oam whether the grant is open or closed. Verified against oam 0.9.0 --
221
+ // bare grant lets an unrelated host through, omitted grant denies it.
222
+ //
223
+ // This mirrors getMalformedUnixUrl's predicate in src/api.ts, NOT the stricter
224
+ // getUnixSocketPath -- deliberately, and the difference is the whole point.
225
+ //
226
+ // getUnixSocketPath accepts only the two WELL-FORMED spellings; matching it
227
+ // here would leave the malformed ones ("unix:/one-slash", "unix://relative",
228
+ // "unix://", or any uppercase spelling, which getUnixSocketPath rejects for
229
+ // case) falling through to the parse, where they yield an empty hostname and
230
+ // the bare `--allow-net` -- fully open, for input that plainly meant a socket.
231
+ //
232
+ // Denying the category for those costs nothing: api.ts routes exactly this set
233
+ // to getMalformedUnixUrl, which fails the request up front with a message
234
+ // naming the spelling error, so no request is ever attempted. Matching the
235
+ // broad predicate is what makes the header's claim true for EVERY unix-ish
236
+ // input rather than just the two tidy ones.
237
+ //
238
+ // `[:/]` after "unix" rather than a bare "unix" prefix, so a real TCP host like
239
+ // "http://unix.example.com:2019" is not swept up -- the same care api.ts takes.
240
+ const isUnixDsn = /^unix[:/]/i.test(dsn);
241
+
242
+ let netFlag = isUnixDsn ? null : "--allow-net";
243
+ if (!isUnixDsn) {
174
244
  try {
175
245
  const u = new URL(dsn);
176
- if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || 2019}`;
246
+ // HOST ONLY, no port, deliberately. Grants are prefix-matched against the
247
+ // resource string, and the resource `fetch` presents is the bare hostname
248
+ // ("localhost") while sockets present "host:port". "localhost" does not
249
+ // start with "localhost:2019", so pinning the port denies every fetch --
250
+ // and fetch is how api.ts talks to a TCP admin endpoint. Granting the host
251
+ // alone also admits the other ports on that host; that is the cost of the
252
+ // check having no port to match against, not an oversight here.
253
+ if (u.hostname) netFlag = `--allow-net=${u.hostname}`;
177
254
  } catch {
178
- // Unparseable CADDY_ADMIN_URL: leave the grant open. The server will fail on
179
- // its own connection error, which names the real problem.
255
+ // Genuinely unparseable CADDY_ADMIN_URL (not the unix forms -- those are
256
+ // handled above): leave the grant open. The server will fail on its own
257
+ // connection error, which names the real problem.
180
258
  }
181
259
  }
182
260
 
183
- const env = ["CADDY_ADMIN_URL", "CADDY_API_TOKEN", "CADDY_LOAD_TIMEOUT", "CADDY_MAX_RETRIES", "CADDY_TIMEOUT"];
261
+ // Every variable the shipped bundle reads (`grep process.env src/`), including
262
+ // CADDY_MCP_SNAPSHOT_DIR in src/snapshots.ts. Omitting one is not a denial the
263
+ // operator can see: the variable is simply ABSENT, so the feature reads as
264
+ // "not configured" and degrades silently.
265
+ const env = [
266
+ "CADDY_ADMIN_URL",
267
+ "CADDY_API_TOKEN",
268
+ "CADDY_LOAD_TIMEOUT",
269
+ "CADDY_MAX_RETRIES",
270
+ "CADDY_MCP_SNAPSHOT_DIR",
271
+ "CADDY_TIMEOUT",
272
+ ];
273
+
274
+ // netFlag is null for a unix DSN -- an OMITTED --allow-net is what denies the
275
+ // category, so it must not survive as a stray "null" argv entry.
276
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`].filter(Boolean);
277
+
278
+ // The filesystem grant exists only when snapshot persistence is switched on,
279
+ // and only for the directory it points at. Granting the variable without the
280
+ // directory would just move the silent failure: src/snapshots.ts swallows its
281
+ // own I/O errors and degrades to the in-memory ring, so `caddy_revert` would
282
+ // quietly stop surviving a restart -- the thing the operator turned the
283
+ // variable on to get.
284
+ //
285
+ // TWO spellings, because grants are matched as plain string PREFIXES against
286
+ // whatever path each call passes: snapshots.ts hands the raw variable to
287
+ // readdirSync/mkdirSync but builds per-file paths with path.join, which
288
+ // normalizes ("./snaps" -> "snaps"). The raw form alone then misses the files;
289
+ // the normalized form alone misses the directory listing.
290
+ //
291
+ // Two consequences worth naming rather than discovering: a prefix also admits
292
+ // a sibling path that merely starts with the same string ("/var/snap" grants
293
+ // "/var/snapshots-elsewhere"), and oam splits the list on commas with no
294
+ // escape, so a directory whose path contains a comma cannot be granted here.
295
+ const snapshotDir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
296
+ if (snapshotDir) {
297
+ const forms = [...new Set([snapshotDir, join(snapshotDir, ".")])].join(",");
298
+ flags.push(`--allow-fs-read=${forms}`, `--allow-fs-write=${forms}`);
299
+ }
184
300
 
185
- const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
186
301
  return flags;
187
302
  }
188
303
 
package/dist/api.d.ts CHANGED
@@ -5,6 +5,25 @@ export interface ApiResponse<T = any> {
5
5
  error?: string;
6
6
  etag?: string;
7
7
  }
8
+ /**
9
+ * Caddy's "a segment of this config path does not exist" failure.
10
+ *
11
+ * Caddy walks a config path one segment at a time and reports the first segment it
12
+ * cannot enter as `invalid traversal path at: <path>`. The STATUS varies with the
13
+ * verb -- 400 on a GET, 500 on a POST, both observed on 2.11.4 -- so the body is
14
+ * the only signal that holds across call sites.
15
+ *
16
+ * DISTINCT from `404 key does not exist`, which Caddy emits when the object exists
17
+ * but the named sub-key does not (a PATCH of an absent issuer field, say). Both
18
+ * mean "what you named is not there", so a caller translating a missing parent
19
+ * usually wants both markers; matching only the 404 form leaves the traversal case
20
+ * falling through as a raw Go error.
21
+ *
22
+ * Exported because two tools need it and they live in different modules: the route
23
+ * tools translate it into "that server does not exist", and caddy_list_servers
24
+ * reads it as "no HTTP servers are configured at all".
25
+ */
26
+ export declare function isMissingConfigPath(res: Pick<ApiResponse, "ok" | "error">): boolean;
8
27
  export declare function configGet<T = any>(path?: string): Promise<ApiResponse<T>>;
9
28
  export declare function configPost<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
10
29
  export declare function configPut<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
package/dist/index.js CHANGED
@@ -88,6 +88,9 @@ function getHeaders(contentType, overUnixSocket = false) {
88
88
  function normalizePath(path) {
89
89
  return path.replace(/^\/?(config(\/|$))?/, "");
90
90
  }
91
+ function encodePathSegments(path) {
92
+ return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
93
+ }
91
94
  function rejectTraversal(path) {
92
95
  if (/(^|\/)\.\.(\/|$)/.test(path)) {
93
96
  return {
@@ -107,6 +110,10 @@ function isTransientFailure(res) {
107
110
  if (res.status >= 500 && res.status <= 599) return true;
108
111
  return false;
109
112
  }
113
+ function isMissingConfigPath(res) {
114
+ if (res.ok) return false;
115
+ return (res.error ?? "").toLowerCase().includes("invalid traversal path");
116
+ }
110
117
  var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
111
118
  function isRetryableMethod(method, path) {
112
119
  if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
@@ -263,31 +270,31 @@ function configGet(path = "") {
263
270
  const normalized = normalizePath(path);
264
271
  const bad = rejectTraversal(normalized);
265
272
  if (bad) return Promise.resolve(bad);
266
- return caddyRequest("GET", `/config/${normalized}`);
273
+ return caddyRequest("GET", `/config/${encodePathSegments(normalized)}`);
267
274
  }
268
275
  function configPost(path, value) {
269
276
  const normalized = normalizePath(path);
270
277
  const bad = rejectTraversal(normalized);
271
278
  if (bad) return Promise.resolve(bad);
272
- return caddyRequest("POST", `/config/${normalized}`, value);
279
+ return caddyRequest("POST", `/config/${encodePathSegments(normalized)}`, value);
273
280
  }
274
281
  function configPut(path, value) {
275
282
  const normalized = normalizePath(path);
276
283
  const bad = rejectTraversal(normalized);
277
284
  if (bad) return Promise.resolve(bad);
278
- return caddyRequest("PUT", `/config/${normalized}`, value);
285
+ return caddyRequest("PUT", `/config/${encodePathSegments(normalized)}`, value);
279
286
  }
280
287
  function configPatch(path, value) {
281
288
  const normalized = normalizePath(path);
282
289
  const bad = rejectTraversal(normalized);
283
290
  if (bad) return Promise.resolve(bad);
284
- return caddyRequest("PATCH", `/config/${normalized}`, value);
291
+ return caddyRequest("PATCH", `/config/${encodePathSegments(normalized)}`, value);
285
292
  }
286
293
  function configDelete(path) {
287
294
  const normalized = normalizePath(path);
288
295
  const bad = rejectTraversal(normalized);
289
296
  if (bad) return Promise.resolve(bad);
290
- return caddyRequest("DELETE", `/config/${normalized}`);
297
+ return caddyRequest("DELETE", `/config/${encodePathSegments(normalized)}`);
291
298
  }
292
299
  function getRequestTimeout() {
293
300
  const raw = process.env.CADDY_TIMEOUT;
@@ -324,19 +331,23 @@ function getUpstreams() {
324
331
  function getPki(ca = "local") {
325
332
  const bad = rejectTraversal(ca);
326
333
  if (bad) return Promise.resolve(bad);
327
- return caddyRequest("GET", `/pki/ca/${ca}`);
334
+ return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}`);
328
335
  }
329
336
  function getPkiCertificates(ca = "local") {
330
337
  const bad = rejectTraversal(ca);
331
338
  if (bad) return Promise.resolve(bad);
332
- return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
339
+ return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}/certificates`);
340
+ }
341
+ function idPath(id, subpath) {
342
+ const encodedId = encodePathSegments(id);
343
+ return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
333
344
  }
334
345
  function configByIdGet(id, subpath = "") {
335
346
  const badId = rejectTraversal(id);
336
347
  if (badId) return Promise.resolve(badId);
337
348
  const bad = rejectTraversal(subpath);
338
349
  if (bad) return Promise.resolve(bad);
339
- const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
350
+ const path = idPath(id, subpath);
340
351
  return caddyRequest("GET", path);
341
352
  }
342
353
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
@@ -344,7 +355,7 @@ function configByIdSet(id, value, method = "PATCH", subpath = "") {
344
355
  if (badId) return Promise.resolve(badId);
345
356
  const bad = rejectTraversal(subpath);
346
357
  if (bad) return Promise.resolve(bad);
347
- const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
358
+ const path = idPath(id, subpath);
348
359
  return caddyRequest(method, path, value);
349
360
  }
350
361
  function configByIdDelete(id, subpath = "") {
@@ -352,7 +363,7 @@ function configByIdDelete(id, subpath = "") {
352
363
  if (badId) return Promise.resolve(badId);
353
364
  const bad = rejectTraversal(subpath);
354
365
  if (bad) return Promise.resolve(bad);
355
- const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
366
+ const path = idPath(id, subpath);
356
367
  return caddyRequest("DELETE", path);
357
368
  }
358
369
  function getMetrics() {
@@ -381,7 +392,8 @@ function describeServer(rawValue) {
381
392
  const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
382
393
  const listen = Array.isArray(raw.listen) ? raw.listen : [];
383
394
  const routes = Array.isArray(raw.routes) ? raw.routes : [];
384
- const hasExplicitTls = !!raw.tls_connection_policies;
395
+ const tlsPolicies = raw.tls_connection_policies;
396
+ const hasExplicitTls = Array.isArray(tlsPolicies) ? tlsPolicies.length > 0 : !!tlsPolicies;
385
397
  const listensHttps = listen.some((l) => typeof l === "string" && HTTPS_PORT_RE.test(l));
386
398
  const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
387
399
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
@@ -465,6 +477,9 @@ ACME email: ${email}`);
465
477
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
466
478
  async () => {
467
479
  const res = await configGet("apps/http/servers");
480
+ if (isMissingConfigPath(res)) {
481
+ return { content: [{ type: "text", text: "No HTTP servers configured" }] };
482
+ }
468
483
  if (!res.ok) return formatResult(res);
469
484
  const servers = res.data ?? {};
470
485
  const names = Object.keys(servers);
@@ -535,6 +550,9 @@ ${lines.join("\n")}` }]
535
550
  }
536
551
 
537
552
  // src/resources.ts
553
+ function errorText(res) {
554
+ return `Error: ${res.error || `HTTP ${res.status}`}`;
555
+ }
538
556
  function registerResources(server) {
539
557
  server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
540
558
  const res = await configGet();
@@ -543,7 +561,7 @@ function registerResources(server) {
543
561
  {
544
562
  uri: "caddy://config",
545
563
  mimeType: res.ok ? "application/json" : "text/plain",
546
- text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
564
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
547
565
  }
548
566
  ]
549
567
  };
@@ -559,7 +577,7 @@ function registerResources(server) {
559
577
  {
560
578
  uri: "caddy://upstreams",
561
579
  mimeType: res.ok ? "application/json" : "text/plain",
562
- text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
580
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
563
581
  }
564
582
  ]
565
583
  };
@@ -579,7 +597,7 @@ function registerResources(server) {
579
597
  {
580
598
  uri: "caddy://metrics",
581
599
  mimeType: "text/plain",
582
- text: `Error: ${res.error}`
600
+ text: errorText(res)
583
601
  }
584
602
  ]
585
603
  };
@@ -607,7 +625,7 @@ function registerResources(server) {
607
625
  {
608
626
  uri: "caddy://servers",
609
627
  mimeType: res.ok ? "application/json" : "text/plain",
610
- text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
628
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
611
629
  }
612
630
  ]
613
631
  };
@@ -768,7 +786,15 @@ function registerConfigTools(server) {
768
786
  path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
769
787
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
770
788
  },
771
- { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
789
+ // idempotentHint is FALSE because the hint covers the whole tool and cannot see
790
+ // the path it is called with. This tool's own documented example ends in an
791
+ // array index -- 'apps/http/servers/srv0/routes/0' -- and Caddy re-packs an
792
+ // array after a delete, so repeating that call removes a DIFFERENT route each
793
+ // time. caddy_remove_route carries the same correction for the byte-identical
794
+ // underlying request; the two must agree. Nothing here is auto-recoverable
795
+ // either: only caddy_load captures a snapshot, so a spurious repeat cannot be
796
+ // undone with caddy_revert.
797
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
772
798
  async ({ path, confirm }) => {
773
799
  if (!confirm) {
774
800
  return {
@@ -880,13 +906,16 @@ ${lines.join("\n")}` }] };
880
906
  const current = await configGet();
881
907
  const res = await loadConfig(snap.config, "application/json");
882
908
  if (!res.ok) return formatResult(res);
883
- if (current.ok && isSnapshotableConfig(current.data)) {
909
+ const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
910
+ if (capturedRollforward) {
884
911
  saveSnapshot(current.data, "caddy_revert");
885
912
  }
886
913
  const when = new Date(snap.timestamp).toISOString();
914
+ const skipped = current.ok ? "the pre-revert config was empty or not a JSON object" : "the pre-revert config could not be read";
915
+ const note = capturedRollforward ? "" : ` Warning: ${skipped}, so no roll-forward snapshot was captured -- this revert cannot be rolled back to the config it replaced.`;
887
916
  return {
888
917
  content: [
889
- { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).` }
918
+ { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).${note}` }
890
919
  ]
891
920
  };
892
921
  }
@@ -904,7 +933,13 @@ ${lines.join("\n")}` }] };
904
933
  ),
905
934
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
906
935
  },
907
- { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
936
+ // destructiveHint is keyed to the worst thing this tool can do, not the
937
+ // default action: action='delete' removes the identified object and every
938
+ // descendant, exactly like caddy_config_delete. Hosts gate on the hint
939
+ // before they can see which action the call carries.
940
+ // idempotentHint stays false for the same reason -- action='set' with
941
+ // mode='append' is a POST, so a repeated call appends a second copy.
942
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
908
943
  async ({ id, action, value, subpath, mode, confirm }) => {
909
944
  if (action === "get") {
910
945
  return formatResult(await configByIdGet(id, subpath));
@@ -934,7 +969,8 @@ ${lines.join("\n")}` }] };
934
969
  }
935
970
  return formatResult(await configByIdDelete(id, subpath));
936
971
  }
937
- return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
972
+ const unhandled = action;
973
+ return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
938
974
  }
939
975
  );
940
976
  }
@@ -945,7 +981,7 @@ var ROUTES_JSON_MAX_CHARS = 2e4;
945
981
  var ROUTES_SUMMARY_MAX = 500;
946
982
  function serializeRoutesCapped(routes) {
947
983
  const parts = [];
948
- let used = 2;
984
+ let used = 3;
949
985
  for (const route of routes) {
950
986
  const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
951
987
  const cost = entry.length + (parts.length > 0 ? 2 : 1);
@@ -995,9 +1031,42 @@ function parseFrom(from) {
995
1031
  function cleanUpstreamAddr(addr) {
996
1032
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
997
1033
  }
1034
+ function withDefaultPort(dial, port) {
1035
+ if (stripPort(dial) !== dial) return dial;
1036
+ const bareIpv6 = !dial.startsWith("[") && dial.indexOf(":") !== dial.lastIndexOf(":");
1037
+ return bareIpv6 ? `[${dial}]:${port}` : `${dial}:${port}`;
1038
+ }
1039
+ function planUpstreams(to) {
1040
+ if (to.length === 0) {
1041
+ return { error: `"to" must list at least one upstream address (e.g. ["localhost:3000"]).` };
1042
+ }
1043
+ const dials = [];
1044
+ let secure = 0;
1045
+ let plain = 0;
1046
+ for (const raw of to) {
1047
+ const trimmed = raw.trim();
1048
+ const isTls = trimmed.startsWith("https://");
1049
+ const dial = cleanUpstreamAddr(trimmed);
1050
+ if (dial.length === 0) {
1051
+ return {
1052
+ error: `upstream ${JSON.stringify(raw)} has no address to dial. Each "to" entry needs a host and port (e.g. "localhost:3000", "https://backend.example.com:8443").`
1053
+ };
1054
+ }
1055
+ if (isTls) secure++;
1056
+ else plain++;
1057
+ dials.push(isTls ? withDefaultPort(dial, 443) : dial);
1058
+ }
1059
+ if (secure > 0 && plain > 0) {
1060
+ return {
1061
+ error: `"to" mixes https:// and non-https upstreams. Caddy's TLS transport applies to the whole reverse_proxy handler, not per-upstream, so one scheme would be silently forced on the other's connection. Split them into two routes, or build the handler explicitly with caddy_add_route.`
1062
+ };
1063
+ }
1064
+ return { dials, tls: secure > 0 };
1065
+ }
998
1066
  function isParentMissing(res) {
999
1067
  if (res.ok) return false;
1000
1068
  if (res.status === 404) return true;
1069
+ if (isMissingConfigPath(res)) return true;
1001
1070
  return res.error?.includes("key does not exist") ?? false;
1002
1071
  }
1003
1072
  function isUnknownId(res) {
@@ -1016,7 +1085,18 @@ function serverNotFoundError(srv, op = "operation") {
1016
1085
  content: [
1017
1086
  {
1018
1087
  type: "text",
1019
- text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
1088
+ text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see what is configured. To create it: caddy_config_set { path: "apps/http/servers/${srv}", mode: "append", value: { "listen": [":443"], "routes": [] } }. Both arguments are load-bearing: mode "append" creates the key, while the default "overwrite" fails with "key does not exist"; and "routes": [] must be present, or adding the first route fails, because a POST creates a missing routes key as an object rather than an array. On an instance with no config at all, use caddy_load instead -- caddy_config_set cannot create the apps/http tree it would write into.`
1089
+ }
1090
+ ]
1091
+ };
1092
+ }
1093
+ function serverNullError(srv) {
1094
+ return {
1095
+ isError: true,
1096
+ content: [
1097
+ {
1098
+ type: "text",
1099
+ text: `Error: Server "${srv}" is not configured, or its config is null -- Caddy returns the same response (HTTP 200 with a body of null) for both, so they cannot be told apart from here. Use caddy_list_servers to see which servers exist, or create this one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
1020
1100
  }
1021
1101
  ]
1022
1102
  };
@@ -1024,10 +1104,12 @@ function serverNotFoundError(srv, op = "operation") {
1024
1104
  function registerRouteTools(server) {
1025
1105
  server.tool(
1026
1106
  "caddy_reverse_proxy",
1027
- "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 PATCH 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.",
1107
+ "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 PATCH 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. Upstream scheme is honored: an `https://` upstream gets a TLS transport and defaults to port 443, anything else is dialed in the clear. A `to` list that MIXES https:// and non-https entries is refused \u2014 the TLS transport applies to the whole handler, not per-upstream \u2014 so split those into two routes or use caddy_add_route.",
1028
1108
  {
1029
1109
  from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
1030
- to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
1110
+ to: z4.array(z4.string().min(1)).min(1).describe(
1111
+ "Upstream addresses, at least one (e.g., ['localhost:3000', 'localhost:3001']). An 'https://' prefix dials the upstream over TLS (port 443 unless one is given); http:// and bare addresses are dialed in the clear. Do not mix https:// and non-https entries in one call."
1112
+ ),
1031
1113
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
1032
1114
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
1033
1115
  "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."
@@ -1047,15 +1129,24 @@ function registerRouteTools(server) {
1047
1129
  ]
1048
1130
  };
1049
1131
  }
1050
- const cleanedTo = to.map(cleanUpstreamAddr);
1132
+ const plan = planUpstreams(to);
1133
+ if ("error" in plan) {
1134
+ return {
1135
+ isError: true,
1136
+ content: [{ type: "text", text: `Error: ${plan.error}` }]
1137
+ };
1138
+ }
1139
+ const cleanedTo = plan.dials;
1140
+ const proxyHandler = {
1141
+ handler: "reverse_proxy",
1142
+ upstreams: cleanedTo.map((addr) => ({ dial: addr }))
1143
+ };
1144
+ if (plan.tls) {
1145
+ proxyHandler.transport = { protocol: "http", tls: {} };
1146
+ }
1051
1147
  const route = {
1052
1148
  match: [match],
1053
- handle: [
1054
- {
1055
- handler: "reverse_proxy",
1056
- upstreams: cleanedTo.map((addr) => ({ dial: addr }))
1057
- }
1058
- ],
1149
+ handle: [proxyHandler],
1059
1150
  terminal: true
1060
1151
  };
1061
1152
  if (id) {
@@ -1143,7 +1234,11 @@ function registerRouteTools(server) {
1143
1234
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1144
1235
  async ({ server: srv }) => {
1145
1236
  const serverRes = await configGet(`apps/http/servers/${srv}`);
1237
+ if (isMissingConfigPath(serverRes)) return serverNotFoundError(srv, "caddy_list_routes");
1146
1238
  if (!serverRes.ok) return formatResult(serverRes);
1239
+ if (serverRes.data === null || serverRes.data === void 0) {
1240
+ return serverNullError(srv);
1241
+ }
1147
1242
  const serverConfig = serverRes.data || {};
1148
1243
  const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
1149
1244
  const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
@@ -1280,14 +1375,19 @@ function registerRouteTools(server) {
1280
1375
  );
1281
1376
  server.tool(
1282
1377
  "caddy_remove_route",
1283
- "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
1378
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible. Only the @id mode is idempotent: a repeat call cannot remove a different route, it just reports the id as gone. The index mode is NOT \u2014 Caddy re-packs the routes array after a removal, so calling with index 2 twice removes TWO DIFFERENT routes. @ids are config-global in Caddy (NOT route-scoped): if `id` resolves to a non-route object (TLS issuer, server, etc.) the call refuses rather than deleting it.",
1284
1379
  {
1285
1380
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
1286
1381
  index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
1287
1382
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
1288
1383
  confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
1289
1384
  },
1290
- { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1385
+ // idempotentHint is false because the hint covers the TOOL, and hosts gate
1386
+ // on it before they can see which targeting mode a given call carries. The
1387
+ // @id path is idempotent; the index path is not -- Caddy re-packs the
1388
+ // routes array on removal, so a repeated index deletes a different route
1389
+ // each time. The weaker of the two has to win.
1390
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1291
1391
  async ({ id, index, server: srv, confirm }) => {
1292
1392
  if (!id && index === void 0) {
1293
1393
  return {
@@ -1308,6 +1408,19 @@ function registerRouteTools(server) {
1308
1408
  };
1309
1409
  }
1310
1410
  if (id) {
1411
+ const existing = await configByIdGet(id);
1412
+ if (!existing.ok) return formatResult(existing);
1413
+ if (!isRouteShape(existing.data)) {
1414
+ return {
1415
+ isError: true,
1416
+ content: [
1417
+ {
1418
+ type: "text",
1419
+ text: `Error: @id "${id}" resolves to a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- refusing to delete it as a route. Inspect it with caddy_config_by_id { id: "${id}", action: "get" }, and if you did mean to remove that object, delete it deliberately with caddy_config_by_id { id: "${id}", action: "delete", confirm: true }.`
1420
+ }
1421
+ ]
1422
+ };
1423
+ }
1311
1424
  const res2 = await configByIdDelete(id);
1312
1425
  if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
1313
1426
  return formatResult(res2);
@@ -1419,6 +1532,30 @@ function validateIssuerShape(tls) {
1419
1532
  }
1420
1533
  return null;
1421
1534
  }
1535
+ var ACME_ISSUER_MODULE = "acme";
1536
+ function validateIssuerModule(tls) {
1537
+ const automation = tls.automation;
1538
+ const found = automation.policies[0].issuers[0].module;
1539
+ if (found === ACME_ISSUER_MODULE) return null;
1540
+ const describe = found === void 0 ? "absent" : JSON.stringify(found);
1541
+ return `Refusing to write an ACME field onto a non-ACME issuer: apps/tls.automation.policies[0].issuers[0].module is ${describe}, not "${ACME_ISSUER_MODULE}". email/ca/profile are fields of the acme issuer module only. Use caddy_config_set with an explicit path to edit this issuer, or point this tool at a config whose first policy uses an acme issuer.`;
1542
+ }
1543
+ function refuseFallback(label, patchRes, detail) {
1544
+ return {
1545
+ kind: "tool-error",
1546
+ result: {
1547
+ isError: true,
1548
+ content: [
1549
+ {
1550
+ type: "text",
1551
+ text: `Error: Failed to set ${label}.
1552
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1553
+ ${detail}`
1554
+ }
1555
+ ]
1556
+ }
1557
+ };
1558
+ }
1422
1559
  async function safeFallback(label, patchRes, fields) {
1423
1560
  const getRes = await configGet("apps/tls");
1424
1561
  const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
@@ -1431,37 +1568,23 @@ async function safeFallback(label, patchRes, fields) {
1431
1568
  return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
1432
1569
  }
1433
1570
  if (!isPlainObject(getRes.data)) {
1434
- return {
1435
- kind: "tool-error",
1436
- result: {
1437
- isError: true,
1438
- content: [
1439
- {
1440
- type: "text",
1441
- text: `Error: Failed to set ${label}.
1442
- PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1443
- Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1444
- }
1445
- ]
1446
- }
1447
- };
1571
+ return refuseFallback(
1572
+ label,
1573
+ patchRes,
1574
+ `Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1575
+ );
1448
1576
  }
1449
1577
  const shapeError = validateIssuerShape(getRes.data);
1450
1578
  if (shapeError) {
1451
- return {
1452
- kind: "tool-error",
1453
- result: {
1454
- isError: true,
1455
- content: [
1456
- {
1457
- type: "text",
1458
- text: `Error: Failed to set ${label}.
1459
- PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1460
- 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.`
1461
- }
1462
- ]
1463
- }
1464
- };
1579
+ return refuseFallback(
1580
+ label,
1581
+ patchRes,
1582
+ `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.`
1583
+ );
1584
+ }
1585
+ const moduleError = validateIssuerModule(getRes.data);
1586
+ if (moduleError) {
1587
+ return refuseFallback(label, patchRes, moduleError);
1465
1588
  }
1466
1589
  const merged = mergeIssuerFields(getRes.data, fields);
1467
1590
  const mergeRes = await configPatch("apps/tls", merged);
@@ -1481,7 +1604,7 @@ function missingArgError(text) {
1481
1604
  function registerTlsTools(server) {
1482
1605
  server.tool(
1483
1606
  "caddy_tls",
1484
- "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
1607
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only, and only when that issuer's module is 'acme' -- on a multi-policy TLS config, or one whose first issuer is 'internal' (Caddy's local CA), edit the intended issuer with caddy_config_set instead.",
1485
1608
  {
1486
1609
  action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1487
1610
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
@@ -1522,7 +1645,8 @@ function registerTlsTools(server) {
1522
1645
  if (!profile) return missingArgError("profile is required for set_acme_profile action");
1523
1646
  return setIssuerField("profile", profile, "ACME profile");
1524
1647
  }
1525
- return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
1648
+ const unhandled = action;
1649
+ return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
1526
1650
  }
1527
1651
  );
1528
1652
  }
package/dist/server.js CHANGED
@@ -86,6 +86,9 @@ function getHeaders(contentType, overUnixSocket = false) {
86
86
  function normalizePath(path) {
87
87
  return path.replace(/^\/?(config(\/|$))?/, "");
88
88
  }
89
+ function encodePathSegments(path) {
90
+ return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
91
+ }
89
92
  function rejectTraversal(path) {
90
93
  if (/(^|\/)\.\.(\/|$)/.test(path)) {
91
94
  return {
@@ -105,6 +108,10 @@ function isTransientFailure(res) {
105
108
  if (res.status >= 500 && res.status <= 599) return true;
106
109
  return false;
107
110
  }
111
+ function isMissingConfigPath(res) {
112
+ if (res.ok) return false;
113
+ return (res.error ?? "").toLowerCase().includes("invalid traversal path");
114
+ }
108
115
  var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
109
116
  function isRetryableMethod(method, path) {
110
117
  if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
@@ -261,31 +268,31 @@ function configGet(path = "") {
261
268
  const normalized = normalizePath(path);
262
269
  const bad = rejectTraversal(normalized);
263
270
  if (bad) return Promise.resolve(bad);
264
- return caddyRequest("GET", `/config/${normalized}`);
271
+ return caddyRequest("GET", `/config/${encodePathSegments(normalized)}`);
265
272
  }
266
273
  function configPost(path, value) {
267
274
  const normalized = normalizePath(path);
268
275
  const bad = rejectTraversal(normalized);
269
276
  if (bad) return Promise.resolve(bad);
270
- return caddyRequest("POST", `/config/${normalized}`, value);
277
+ return caddyRequest("POST", `/config/${encodePathSegments(normalized)}`, value);
271
278
  }
272
279
  function configPut(path, value) {
273
280
  const normalized = normalizePath(path);
274
281
  const bad = rejectTraversal(normalized);
275
282
  if (bad) return Promise.resolve(bad);
276
- return caddyRequest("PUT", `/config/${normalized}`, value);
283
+ return caddyRequest("PUT", `/config/${encodePathSegments(normalized)}`, value);
277
284
  }
278
285
  function configPatch(path, value) {
279
286
  const normalized = normalizePath(path);
280
287
  const bad = rejectTraversal(normalized);
281
288
  if (bad) return Promise.resolve(bad);
282
- return caddyRequest("PATCH", `/config/${normalized}`, value);
289
+ return caddyRequest("PATCH", `/config/${encodePathSegments(normalized)}`, value);
283
290
  }
284
291
  function configDelete(path) {
285
292
  const normalized = normalizePath(path);
286
293
  const bad = rejectTraversal(normalized);
287
294
  if (bad) return Promise.resolve(bad);
288
- return caddyRequest("DELETE", `/config/${normalized}`);
295
+ return caddyRequest("DELETE", `/config/${encodePathSegments(normalized)}`);
289
296
  }
290
297
  function getRequestTimeout() {
291
298
  const raw = process.env.CADDY_TIMEOUT;
@@ -322,19 +329,23 @@ function getUpstreams() {
322
329
  function getPki(ca = "local") {
323
330
  const bad = rejectTraversal(ca);
324
331
  if (bad) return Promise.resolve(bad);
325
- return caddyRequest("GET", `/pki/ca/${ca}`);
332
+ return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}`);
326
333
  }
327
334
  function getPkiCertificates(ca = "local") {
328
335
  const bad = rejectTraversal(ca);
329
336
  if (bad) return Promise.resolve(bad);
330
- return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
337
+ return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}/certificates`);
338
+ }
339
+ function idPath(id, subpath) {
340
+ const encodedId = encodePathSegments(id);
341
+ return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
331
342
  }
332
343
  function configByIdGet(id, subpath = "") {
333
344
  const badId = rejectTraversal(id);
334
345
  if (badId) return Promise.resolve(badId);
335
346
  const bad = rejectTraversal(subpath);
336
347
  if (bad) return Promise.resolve(bad);
337
- const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
348
+ const path = idPath(id, subpath);
338
349
  return caddyRequest("GET", path);
339
350
  }
340
351
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
@@ -342,7 +353,7 @@ function configByIdSet(id, value, method = "PATCH", subpath = "") {
342
353
  if (badId) return Promise.resolve(badId);
343
354
  const bad = rejectTraversal(subpath);
344
355
  if (bad) return Promise.resolve(bad);
345
- const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
356
+ const path = idPath(id, subpath);
346
357
  return caddyRequest(method, path, value);
347
358
  }
348
359
  function configByIdDelete(id, subpath = "") {
@@ -350,7 +361,7 @@ function configByIdDelete(id, subpath = "") {
350
361
  if (badId) return Promise.resolve(badId);
351
362
  const bad = rejectTraversal(subpath);
352
363
  if (bad) return Promise.resolve(bad);
353
- const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
364
+ const path = idPath(id, subpath);
354
365
  return caddyRequest("DELETE", path);
355
366
  }
356
367
  function getMetrics() {
@@ -379,7 +390,8 @@ function describeServer(rawValue) {
379
390
  const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
380
391
  const listen = Array.isArray(raw.listen) ? raw.listen : [];
381
392
  const routes = Array.isArray(raw.routes) ? raw.routes : [];
382
- const hasExplicitTls = !!raw.tls_connection_policies;
393
+ const tlsPolicies = raw.tls_connection_policies;
394
+ const hasExplicitTls = Array.isArray(tlsPolicies) ? tlsPolicies.length > 0 : !!tlsPolicies;
383
395
  const listensHttps = listen.some((l) => typeof l === "string" && HTTPS_PORT_RE.test(l));
384
396
  const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
385
397
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
@@ -463,6 +475,9 @@ ACME email: ${email}`);
463
475
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
464
476
  async () => {
465
477
  const res = await configGet("apps/http/servers");
478
+ if (isMissingConfigPath(res)) {
479
+ return { content: [{ type: "text", text: "No HTTP servers configured" }] };
480
+ }
466
481
  if (!res.ok) return formatResult(res);
467
482
  const servers = res.data ?? {};
468
483
  const names = Object.keys(servers);
@@ -533,6 +548,9 @@ ${lines.join("\n")}` }]
533
548
  }
534
549
 
535
550
  // src/resources.ts
551
+ function errorText(res) {
552
+ return `Error: ${res.error || `HTTP ${res.status}`}`;
553
+ }
536
554
  function registerResources(server) {
537
555
  server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
538
556
  const res = await configGet();
@@ -541,7 +559,7 @@ function registerResources(server) {
541
559
  {
542
560
  uri: "caddy://config",
543
561
  mimeType: res.ok ? "application/json" : "text/plain",
544
- text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
562
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
545
563
  }
546
564
  ]
547
565
  };
@@ -557,7 +575,7 @@ function registerResources(server) {
557
575
  {
558
576
  uri: "caddy://upstreams",
559
577
  mimeType: res.ok ? "application/json" : "text/plain",
560
- text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
578
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
561
579
  }
562
580
  ]
563
581
  };
@@ -577,7 +595,7 @@ function registerResources(server) {
577
595
  {
578
596
  uri: "caddy://metrics",
579
597
  mimeType: "text/plain",
580
- text: `Error: ${res.error}`
598
+ text: errorText(res)
581
599
  }
582
600
  ]
583
601
  };
@@ -605,7 +623,7 @@ function registerResources(server) {
605
623
  {
606
624
  uri: "caddy://servers",
607
625
  mimeType: res.ok ? "application/json" : "text/plain",
608
- text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
626
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
609
627
  }
610
628
  ]
611
629
  };
@@ -766,7 +784,15 @@ function registerConfigTools(server) {
766
784
  path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
767
785
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
768
786
  },
769
- { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
787
+ // idempotentHint is FALSE because the hint covers the whole tool and cannot see
788
+ // the path it is called with. This tool's own documented example ends in an
789
+ // array index -- 'apps/http/servers/srv0/routes/0' -- and Caddy re-packs an
790
+ // array after a delete, so repeating that call removes a DIFFERENT route each
791
+ // time. caddy_remove_route carries the same correction for the byte-identical
792
+ // underlying request; the two must agree. Nothing here is auto-recoverable
793
+ // either: only caddy_load captures a snapshot, so a spurious repeat cannot be
794
+ // undone with caddy_revert.
795
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
770
796
  async ({ path, confirm }) => {
771
797
  if (!confirm) {
772
798
  return {
@@ -878,13 +904,16 @@ ${lines.join("\n")}` }] };
878
904
  const current = await configGet();
879
905
  const res = await loadConfig(snap.config, "application/json");
880
906
  if (!res.ok) return formatResult(res);
881
- if (current.ok && isSnapshotableConfig(current.data)) {
907
+ const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
908
+ if (capturedRollforward) {
882
909
  saveSnapshot(current.data, "caddy_revert");
883
910
  }
884
911
  const when = new Date(snap.timestamp).toISOString();
912
+ const skipped = current.ok ? "the pre-revert config was empty or not a JSON object" : "the pre-revert config could not be read";
913
+ const note = capturedRollforward ? "" : ` Warning: ${skipped}, so no roll-forward snapshot was captured -- this revert cannot be rolled back to the config it replaced.`;
885
914
  return {
886
915
  content: [
887
- { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).` }
916
+ { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).${note}` }
888
917
  ]
889
918
  };
890
919
  }
@@ -902,7 +931,13 @@ ${lines.join("\n")}` }] };
902
931
  ),
903
932
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
904
933
  },
905
- { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
934
+ // destructiveHint is keyed to the worst thing this tool can do, not the
935
+ // default action: action='delete' removes the identified object and every
936
+ // descendant, exactly like caddy_config_delete. Hosts gate on the hint
937
+ // before they can see which action the call carries.
938
+ // idempotentHint stays false for the same reason -- action='set' with
939
+ // mode='append' is a POST, so a repeated call appends a second copy.
940
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
906
941
  async ({ id, action, value, subpath, mode, confirm }) => {
907
942
  if (action === "get") {
908
943
  return formatResult(await configByIdGet(id, subpath));
@@ -932,7 +967,8 @@ ${lines.join("\n")}` }] };
932
967
  }
933
968
  return formatResult(await configByIdDelete(id, subpath));
934
969
  }
935
- return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
970
+ const unhandled = action;
971
+ return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
936
972
  }
937
973
  );
938
974
  }
@@ -943,7 +979,7 @@ var ROUTES_JSON_MAX_CHARS = 2e4;
943
979
  var ROUTES_SUMMARY_MAX = 500;
944
980
  function serializeRoutesCapped(routes) {
945
981
  const parts = [];
946
- let used = 2;
982
+ let used = 3;
947
983
  for (const route of routes) {
948
984
  const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
949
985
  const cost = entry.length + (parts.length > 0 ? 2 : 1);
@@ -993,9 +1029,42 @@ function parseFrom(from) {
993
1029
  function cleanUpstreamAddr(addr) {
994
1030
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
995
1031
  }
1032
+ function withDefaultPort(dial, port) {
1033
+ if (stripPort(dial) !== dial) return dial;
1034
+ const bareIpv6 = !dial.startsWith("[") && dial.indexOf(":") !== dial.lastIndexOf(":");
1035
+ return bareIpv6 ? `[${dial}]:${port}` : `${dial}:${port}`;
1036
+ }
1037
+ function planUpstreams(to) {
1038
+ if (to.length === 0) {
1039
+ return { error: `"to" must list at least one upstream address (e.g. ["localhost:3000"]).` };
1040
+ }
1041
+ const dials = [];
1042
+ let secure = 0;
1043
+ let plain = 0;
1044
+ for (const raw of to) {
1045
+ const trimmed = raw.trim();
1046
+ const isTls = trimmed.startsWith("https://");
1047
+ const dial = cleanUpstreamAddr(trimmed);
1048
+ if (dial.length === 0) {
1049
+ return {
1050
+ error: `upstream ${JSON.stringify(raw)} has no address to dial. Each "to" entry needs a host and port (e.g. "localhost:3000", "https://backend.example.com:8443").`
1051
+ };
1052
+ }
1053
+ if (isTls) secure++;
1054
+ else plain++;
1055
+ dials.push(isTls ? withDefaultPort(dial, 443) : dial);
1056
+ }
1057
+ if (secure > 0 && plain > 0) {
1058
+ return {
1059
+ error: `"to" mixes https:// and non-https upstreams. Caddy's TLS transport applies to the whole reverse_proxy handler, not per-upstream, so one scheme would be silently forced on the other's connection. Split them into two routes, or build the handler explicitly with caddy_add_route.`
1060
+ };
1061
+ }
1062
+ return { dials, tls: secure > 0 };
1063
+ }
996
1064
  function isParentMissing(res) {
997
1065
  if (res.ok) return false;
998
1066
  if (res.status === 404) return true;
1067
+ if (isMissingConfigPath(res)) return true;
999
1068
  return res.error?.includes("key does not exist") ?? false;
1000
1069
  }
1001
1070
  function isUnknownId(res) {
@@ -1014,7 +1083,18 @@ function serverNotFoundError(srv, op = "operation") {
1014
1083
  content: [
1015
1084
  {
1016
1085
  type: "text",
1017
- text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
1086
+ text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see what is configured. To create it: caddy_config_set { path: "apps/http/servers/${srv}", mode: "append", value: { "listen": [":443"], "routes": [] } }. Both arguments are load-bearing: mode "append" creates the key, while the default "overwrite" fails with "key does not exist"; and "routes": [] must be present, or adding the first route fails, because a POST creates a missing routes key as an object rather than an array. On an instance with no config at all, use caddy_load instead -- caddy_config_set cannot create the apps/http tree it would write into.`
1087
+ }
1088
+ ]
1089
+ };
1090
+ }
1091
+ function serverNullError(srv) {
1092
+ return {
1093
+ isError: true,
1094
+ content: [
1095
+ {
1096
+ type: "text",
1097
+ text: `Error: Server "${srv}" is not configured, or its config is null -- Caddy returns the same response (HTTP 200 with a body of null) for both, so they cannot be told apart from here. Use caddy_list_servers to see which servers exist, or create this one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
1018
1098
  }
1019
1099
  ]
1020
1100
  };
@@ -1022,10 +1102,12 @@ function serverNotFoundError(srv, op = "operation") {
1022
1102
  function registerRouteTools(server) {
1023
1103
  server.tool(
1024
1104
  "caddy_reverse_proxy",
1025
- "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 PATCH 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.",
1105
+ "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 PATCH 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. Upstream scheme is honored: an `https://` upstream gets a TLS transport and defaults to port 443, anything else is dialed in the clear. A `to` list that MIXES https:// and non-https entries is refused \u2014 the TLS transport applies to the whole handler, not per-upstream \u2014 so split those into two routes or use caddy_add_route.",
1026
1106
  {
1027
1107
  from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
1028
- to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
1108
+ to: z4.array(z4.string().min(1)).min(1).describe(
1109
+ "Upstream addresses, at least one (e.g., ['localhost:3000', 'localhost:3001']). An 'https://' prefix dials the upstream over TLS (port 443 unless one is given); http:// and bare addresses are dialed in the clear. Do not mix https:// and non-https entries in one call."
1110
+ ),
1029
1111
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
1030
1112
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
1031
1113
  "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."
@@ -1045,15 +1127,24 @@ function registerRouteTools(server) {
1045
1127
  ]
1046
1128
  };
1047
1129
  }
1048
- const cleanedTo = to.map(cleanUpstreamAddr);
1130
+ const plan = planUpstreams(to);
1131
+ if ("error" in plan) {
1132
+ return {
1133
+ isError: true,
1134
+ content: [{ type: "text", text: `Error: ${plan.error}` }]
1135
+ };
1136
+ }
1137
+ const cleanedTo = plan.dials;
1138
+ const proxyHandler = {
1139
+ handler: "reverse_proxy",
1140
+ upstreams: cleanedTo.map((addr) => ({ dial: addr }))
1141
+ };
1142
+ if (plan.tls) {
1143
+ proxyHandler.transport = { protocol: "http", tls: {} };
1144
+ }
1049
1145
  const route = {
1050
1146
  match: [match],
1051
- handle: [
1052
- {
1053
- handler: "reverse_proxy",
1054
- upstreams: cleanedTo.map((addr) => ({ dial: addr }))
1055
- }
1056
- ],
1147
+ handle: [proxyHandler],
1057
1148
  terminal: true
1058
1149
  };
1059
1150
  if (id) {
@@ -1141,7 +1232,11 @@ function registerRouteTools(server) {
1141
1232
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1142
1233
  async ({ server: srv }) => {
1143
1234
  const serverRes = await configGet(`apps/http/servers/${srv}`);
1235
+ if (isMissingConfigPath(serverRes)) return serverNotFoundError(srv, "caddy_list_routes");
1144
1236
  if (!serverRes.ok) return formatResult(serverRes);
1237
+ if (serverRes.data === null || serverRes.data === void 0) {
1238
+ return serverNullError(srv);
1239
+ }
1145
1240
  const serverConfig = serverRes.data || {};
1146
1241
  const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
1147
1242
  const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
@@ -1278,14 +1373,19 @@ function registerRouteTools(server) {
1278
1373
  );
1279
1374
  server.tool(
1280
1375
  "caddy_remove_route",
1281
- "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
1376
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible. Only the @id mode is idempotent: a repeat call cannot remove a different route, it just reports the id as gone. The index mode is NOT \u2014 Caddy re-packs the routes array after a removal, so calling with index 2 twice removes TWO DIFFERENT routes. @ids are config-global in Caddy (NOT route-scoped): if `id` resolves to a non-route object (TLS issuer, server, etc.) the call refuses rather than deleting it.",
1282
1377
  {
1283
1378
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
1284
1379
  index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
1285
1380
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
1286
1381
  confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
1287
1382
  },
1288
- { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1383
+ // idempotentHint is false because the hint covers the TOOL, and hosts gate
1384
+ // on it before they can see which targeting mode a given call carries. The
1385
+ // @id path is idempotent; the index path is not -- Caddy re-packs the
1386
+ // routes array on removal, so a repeated index deletes a different route
1387
+ // each time. The weaker of the two has to win.
1388
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1289
1389
  async ({ id, index, server: srv, confirm }) => {
1290
1390
  if (!id && index === void 0) {
1291
1391
  return {
@@ -1306,6 +1406,19 @@ function registerRouteTools(server) {
1306
1406
  };
1307
1407
  }
1308
1408
  if (id) {
1409
+ const existing = await configByIdGet(id);
1410
+ if (!existing.ok) return formatResult(existing);
1411
+ if (!isRouteShape(existing.data)) {
1412
+ return {
1413
+ isError: true,
1414
+ content: [
1415
+ {
1416
+ type: "text",
1417
+ text: `Error: @id "${id}" resolves to a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- refusing to delete it as a route. Inspect it with caddy_config_by_id { id: "${id}", action: "get" }, and if you did mean to remove that object, delete it deliberately with caddy_config_by_id { id: "${id}", action: "delete", confirm: true }.`
1418
+ }
1419
+ ]
1420
+ };
1421
+ }
1309
1422
  const res2 = await configByIdDelete(id);
1310
1423
  if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
1311
1424
  return formatResult(res2);
@@ -1417,6 +1530,30 @@ function validateIssuerShape(tls) {
1417
1530
  }
1418
1531
  return null;
1419
1532
  }
1533
+ var ACME_ISSUER_MODULE = "acme";
1534
+ function validateIssuerModule(tls) {
1535
+ const automation = tls.automation;
1536
+ const found = automation.policies[0].issuers[0].module;
1537
+ if (found === ACME_ISSUER_MODULE) return null;
1538
+ const describe = found === void 0 ? "absent" : JSON.stringify(found);
1539
+ return `Refusing to write an ACME field onto a non-ACME issuer: apps/tls.automation.policies[0].issuers[0].module is ${describe}, not "${ACME_ISSUER_MODULE}". email/ca/profile are fields of the acme issuer module only. Use caddy_config_set with an explicit path to edit this issuer, or point this tool at a config whose first policy uses an acme issuer.`;
1540
+ }
1541
+ function refuseFallback(label, patchRes, detail) {
1542
+ return {
1543
+ kind: "tool-error",
1544
+ result: {
1545
+ isError: true,
1546
+ content: [
1547
+ {
1548
+ type: "text",
1549
+ text: `Error: Failed to set ${label}.
1550
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1551
+ ${detail}`
1552
+ }
1553
+ ]
1554
+ }
1555
+ };
1556
+ }
1420
1557
  async function safeFallback(label, patchRes, fields) {
1421
1558
  const getRes = await configGet("apps/tls");
1422
1559
  const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
@@ -1429,37 +1566,23 @@ async function safeFallback(label, patchRes, fields) {
1429
1566
  return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
1430
1567
  }
1431
1568
  if (!isPlainObject(getRes.data)) {
1432
- return {
1433
- kind: "tool-error",
1434
- result: {
1435
- isError: true,
1436
- content: [
1437
- {
1438
- type: "text",
1439
- text: `Error: Failed to set ${label}.
1440
- PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1441
- Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1442
- }
1443
- ]
1444
- }
1445
- };
1569
+ return refuseFallback(
1570
+ label,
1571
+ patchRes,
1572
+ `Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1573
+ );
1446
1574
  }
1447
1575
  const shapeError = validateIssuerShape(getRes.data);
1448
1576
  if (shapeError) {
1449
- return {
1450
- kind: "tool-error",
1451
- result: {
1452
- isError: true,
1453
- content: [
1454
- {
1455
- type: "text",
1456
- text: `Error: Failed to set ${label}.
1457
- PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1458
- 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.`
1459
- }
1460
- ]
1461
- }
1462
- };
1577
+ return refuseFallback(
1578
+ label,
1579
+ patchRes,
1580
+ `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.`
1581
+ );
1582
+ }
1583
+ const moduleError = validateIssuerModule(getRes.data);
1584
+ if (moduleError) {
1585
+ return refuseFallback(label, patchRes, moduleError);
1463
1586
  }
1464
1587
  const merged = mergeIssuerFields(getRes.data, fields);
1465
1588
  const mergeRes = await configPatch("apps/tls", merged);
@@ -1479,7 +1602,7 @@ function missingArgError(text) {
1479
1602
  function registerTlsTools(server) {
1480
1603
  server.tool(
1481
1604
  "caddy_tls",
1482
- "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
1605
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only, and only when that issuer's module is 'acme' -- on a multi-policy TLS config, or one whose first issuer is 'internal' (Caddy's local CA), edit the intended issuer with caddy_config_set instead.",
1483
1606
  {
1484
1607
  action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1485
1608
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
@@ -1520,7 +1643,8 @@ function registerTlsTools(server) {
1520
1643
  if (!profile) return missingArgError("profile is required for set_acme_profile action");
1521
1644
  return setIssuerField("profile", profile, "ACME profile");
1522
1645
  }
1523
- return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
1646
+ const unhandled = action;
1647
+ return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
1524
1648
  }
1525
1649
  );
1526
1650
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.4.1",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
- "description": "MCP server for managing Caddy web servers via the admin API",
5
+ "description": "Caddy MCP server for Claude Code, Cursor, and any MCP client: admin API, config, routes, reverse proxy, TLS, PKI, metrics",
6
6
  "license": "MIT",
7
7
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
8
8
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "build": "tsup && tsc -p tsconfig.build.json",
29
29
  "dev": "tsup --watch",
30
30
  "test": "vitest run",
31
- "lint": "biome check src/ bin/ scripts/",
32
- "lint:fix": "biome check --write src/ bin/ scripts/",
31
+ "lint": "node scripts/lint.mjs check src/ bin/ scripts/",
32
+ "lint:fix": "node scripts/lint.mjs check --write src/ bin/ scripts/",
33
33
  "typecheck": "node scripts/typecheck.mjs",
34
34
  "typecheck:tsc": "tsc --noEmit",
35
35
  "test:ci": "npm run build && npm test",
@@ -53,6 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@biomejs/biome": "~2.4.11",
55
55
  "@types/node": "^26.0.0",
56
+ "esbuild": "^0.28.1",
56
57
  "postject": "^1.0.0-alpha.6",
57
58
  "tsup": "^8.4.0",
58
59
  "typescript": "^7.0.2",
@@ -62,12 +63,22 @@
62
63
  "node": ">=20"
63
64
  },
64
65
  "keywords": [
66
+ "caddy",
67
+ "caddy-mcp",
68
+ "caddy-server",
69
+ "caddyfile",
65
70
  "mcp",
71
+ "mcp-server",
66
72
  "model-context-protocol",
67
- "caddy",
68
73
  "reverse-proxy",
69
74
  "web-server",
70
- "mcp-server",
75
+ "admin-api",
76
+ "tls",
77
+ "acme",
78
+ "pki",
79
+ "prometheus",
80
+ "claude-code",
81
+ "cursor",
71
82
  "devtools",
72
83
  "ai"
73
84
  ],
@@ -78,5 +89,5 @@
78
89
  "bugs": {
79
90
  "url": "https://github.com/YawLabs/caddy-mcp/issues"
80
91
  },
81
- "homepage": "https://github.com/YawLabs/caddy-mcp"
92
+ "homepage": "https://yaw.sh/mcp-servers/caddy-mcp/"
82
93
  }