@yawlabs/caddy-mcp 2.5.2 → 2.5.4

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/dist/server.js CHANGED
@@ -12,6 +12,7 @@ var RETRY_MAX_DELAY_MS = 2e3;
12
12
  var RETRY_MAX_JITTER_MS = 50;
13
13
  var RETRY_HARD_CAP = 5;
14
14
  var ADMIN_RESTART_SETTLE_MS = 250;
15
+ var LOAD_TIMEOUT = 55e3;
15
16
  var etagCache = /* @__PURE__ */ new Map();
16
17
  var MAX_ETAG_CACHE = 256;
17
18
  function setEtag(path, etag) {
@@ -21,6 +22,54 @@ function setEtag(path, etag) {
21
22
  }
22
23
  etagCache.set(path, etag);
23
24
  }
25
+ var GO_WHITESPACE = /* @__PURE__ */ new Set([
26
+ 9,
27
+ 10,
28
+ 11,
29
+ 12,
30
+ 13,
31
+ 32,
32
+ 133,
33
+ 160,
34
+ 5760,
35
+ 8192,
36
+ 8193,
37
+ 8194,
38
+ 8195,
39
+ 8196,
40
+ 8197,
41
+ 8198,
42
+ 8199,
43
+ 8200,
44
+ 8201,
45
+ 8202,
46
+ 8232,
47
+ 8233,
48
+ 8239,
49
+ 8287,
50
+ 12288
51
+ ]);
52
+ function goFields(text) {
53
+ const fields = [];
54
+ let current = "";
55
+ for (const ch of text) {
56
+ if (GO_WHITESPACE.has(ch.codePointAt(0))) {
57
+ if (current) fields.push(current);
58
+ current = "";
59
+ } else {
60
+ current += ch;
61
+ }
62
+ }
63
+ if (current) fields.push(current);
64
+ return fields;
65
+ }
66
+ function isEchoableEtag(etag) {
67
+ const decoded = Buffer.from(etag, "latin1").toString("utf8");
68
+ if (decoded.length < 2 || !decoded.startsWith('"') || !decoded.endsWith('"')) return false;
69
+ const inner = decoded.slice(1, -1);
70
+ const fields = goFields(inner);
71
+ return fields.length === 2 && fields.join(" ") === inner;
72
+ }
24
73
  function isAncestorOf(ancestor, descendant) {
25
74
  const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
26
75
  return descendant.startsWith(`${base}/`);
@@ -106,25 +155,36 @@ function sleep(ms) {
106
155
  function isTransientFailure(res) {
107
156
  if (res.ok) return false;
108
157
  if (res.status === 0) return true;
109
- if (res.status >= 500 && res.status <= 599) return true;
110
- return false;
158
+ return res.status === 502 || res.status === 503 || res.status === 504;
111
159
  }
112
160
  function isMissingConfigPath(res) {
113
161
  if (res.ok) return false;
114
162
  return (res.error ?? "").toLowerCase().includes("invalid traversal path");
115
163
  }
116
- var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
164
+ var ARRAY_INDEX_TAIL_RE = /\/\d+(\/+\.\.\.)?\/*$/;
165
+ var BARE_ID_RE = /^\/id\/[^/]+(\/+\.\.\.)?\/*$/;
166
+ function isConfigChange(method, path) {
167
+ if (method === "GET") return false;
168
+ return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
169
+ }
117
170
  function isRetryableMethod(method, path) {
118
- if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
171
+ if (method === "PUT" && BARE_ID_RE.test(path)) return false;
172
+ if (method === "PUT" || method === "DELETE") return !ARRAY_INDEX_TAIL_RE.test(path);
119
173
  if (method !== "POST") return true;
120
174
  return !path.startsWith("/config/") && !path.startsWith("/id/");
121
175
  }
176
+ function shouldRetry(method, path, attempt) {
177
+ if (!isTransientFailure(attempt.res)) return false;
178
+ if (attempt.refused) return true;
179
+ if (attempt.timedOut && isConfigChange(method, path)) return false;
180
+ return isRetryableMethod(method, path);
181
+ }
122
182
  function getMalformedUnixUrl() {
123
183
  const raw = (process.env.CADDY_ADMIN_URL || "").trim();
124
184
  if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
125
185
  return getUnixSocketPath() === void 0 ? raw : void 0;
126
186
  }
127
- async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
187
+ async function caddyRequest(method, path, body, contentType, rawStringBody = false) {
128
188
  const malformed = getMalformedUnixUrl();
129
189
  if (malformed) {
130
190
  return {
@@ -134,16 +194,16 @@ async function caddyRequest(method, path, body, contentType, timeout, rawStringB
134
194
  };
135
195
  }
136
196
  const maxRetries = getMaxRetries();
137
- let attempt = 0;
138
- let { res, refused } = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
139
- while (isTransientFailure(res) && (refused || isRetryableMethod(method, path)) && attempt < maxRetries) {
140
- attempt++;
141
- const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
197
+ let retries = 0;
198
+ let attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
199
+ while (retries < maxRetries && shouldRetry(method, path, attempt)) {
200
+ retries++;
201
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
142
202
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
143
203
  await sleep(delay);
144
- ({ res, refused } = await attemptRequest(method, path, body, contentType, timeout, rawStringBody));
204
+ attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
145
205
  }
146
- return res;
206
+ return attempt.res;
147
207
  }
148
208
  function isConnectionRefused(err) {
149
209
  let current = err;
@@ -157,27 +217,44 @@ function isConnectionRefused(err) {
157
217
  }
158
218
  return false;
159
219
  }
220
+ function isTimeoutError(err) {
221
+ let current = err;
222
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
223
+ const e = current;
224
+ if (e.name === "TimeoutError" || e.name === "AbortError") return true;
225
+ current = e.cause;
226
+ }
227
+ return false;
228
+ }
160
229
  function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
161
230
  return new Promise((resolve, reject) => {
162
- const req = httpRequest(
163
- { socketPath, path, method, headers, agent: false, signal: AbortSignal.timeout(timeoutMs) },
164
- (res) => {
165
- const chunks = [];
166
- res.on("data", (chunk) => chunks.push(chunk));
167
- res.on("error", reject);
168
- res.on("end", () => {
169
- const status = res.statusCode ?? 0;
170
- const etag = res.headers.etag;
171
- resolve({
172
- ok: status >= 200 && status < 300,
173
- status,
174
- text: Buffer.concat(chunks).toString("utf8"),
175
- etag: typeof etag === "string" ? etag : void 0
176
- });
231
+ let deadlineHit = false;
232
+ const deadline = Object.assign(new Error(`timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
233
+ function fail(err) {
234
+ clearTimeout(timer);
235
+ reject(deadlineHit ? deadline : err);
236
+ }
237
+ const req = httpRequest({ socketPath, path, method, headers, agent: false }, (res) => {
238
+ const chunks = [];
239
+ res.on("data", (chunk) => chunks.push(chunk));
240
+ res.on("error", fail);
241
+ res.on("end", () => {
242
+ clearTimeout(timer);
243
+ const status = res.statusCode ?? 0;
244
+ const etag = res.headers.etag;
245
+ resolve({
246
+ ok: status >= 200 && status < 300,
247
+ status,
248
+ text: Buffer.concat(chunks).toString("utf8"),
249
+ etag: typeof etag === "string" ? etag : void 0
177
250
  });
178
- }
179
- );
180
- req.on("error", reject);
251
+ });
252
+ });
253
+ const timer = setTimeout(() => {
254
+ deadlineHit = true;
255
+ req.destroy(deadline);
256
+ }, timeoutMs);
257
+ req.on("error", fail);
181
258
  if (body !== void 0) req.write(body);
182
259
  req.end();
183
260
  });
@@ -236,19 +313,65 @@ function settleAdminRestart(origin) {
236
313
  socketWaiters.add(check);
237
314
  });
238
315
  }
239
- function isConfigChange(method, path) {
240
- if (method === "GET") return false;
241
- return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
316
+ function endOfFirstJsonValue(text) {
317
+ let depth = 0;
318
+ let inString = false;
319
+ let escaped = false;
320
+ for (let i = 0; i < text.length; i++) {
321
+ const ch = text[i];
322
+ if (inString) {
323
+ if (escaped) escaped = false;
324
+ else if (ch === "\\") escaped = true;
325
+ else if (ch === '"') inString = false;
326
+ continue;
327
+ }
328
+ if (ch === '"') inString = true;
329
+ else if (ch === "[" || ch === "{") depth++;
330
+ else if (ch === "]" || ch === "}") {
331
+ depth--;
332
+ if (depth <= 0) return depth === 0 ? i + 1 : -1;
333
+ }
334
+ }
335
+ return -1;
336
+ }
337
+ function parseJsonOrUndefined(text) {
338
+ try {
339
+ return JSON.parse(text);
340
+ } catch {
341
+ return void 0;
342
+ }
242
343
  }
243
- async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
244
- const transport = { refused: false };
245
- const res = await sendOnce(transport, method, path, body, contentType, timeout, rawStringBody);
246
- return { res, refused: transport.refused };
344
+ function readLoadBody(text) {
345
+ const body = text.trim();
346
+ if (body.startsWith("[")) {
347
+ const end = endOfFirstJsonValue(body);
348
+ if (end === -1) return void 0;
349
+ const warnings = parseJsonOrUndefined(body.slice(0, end));
350
+ if (!Array.isArray(warnings)) return void 0;
351
+ const tail = body.slice(end).trim();
352
+ if (!tail) return { warnings };
353
+ const trailing = parseJsonOrUndefined(tail);
354
+ if (trailing === null || typeof trailing !== "object" || Array.isArray(trailing)) return void 0;
355
+ if (typeof trailing.error !== "string") return void 0;
356
+ return { warnings, errorText: tail };
357
+ }
358
+ if (body.startsWith("{")) {
359
+ const parsed = parseJsonOrUndefined(body);
360
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
361
+ const warnings = parsed.warnings;
362
+ if (Array.isArray(warnings) && Object.keys(parsed).length === 1) return { warnings };
363
+ }
364
+ return void 0;
247
365
  }
248
- async function sendOnce(transport, method, path, body, contentType, timeout, rawStringBody = false) {
366
+ async function attemptRequest(method, path, body, contentType, rawStringBody = false) {
367
+ const transport = { refused: false, timedOut: false };
368
+ const res = await sendOnce(transport, method, path, body, contentType, rawStringBody);
369
+ return { res, refused: transport.refused, timedOut: transport.timedOut };
370
+ }
371
+ async function sendOnce(transport, method, path, body, contentType, rawStringBody = false) {
249
372
  const socketPath = getUnixSocketPath();
250
373
  const url = `${getBaseUrl()}${path}`;
251
- const effectiveTimeout = timeout ?? getRequestTimeout();
374
+ const effectiveTimeout = getTimeoutFor(method, path);
252
375
  try {
253
376
  const hasBody = body !== void 0;
254
377
  const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
@@ -260,11 +383,21 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
260
383
  }
261
384
  const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
262
385
  const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
386
+ const loadBody = path === "/load" && res.ok ? readLoadBody(res.text) : void 0;
387
+ if (loadBody?.errorText !== void 0) {
388
+ return {
389
+ ok: false,
390
+ status: res.status,
391
+ error: `${loadBody.errorText} -- Caddy answered HTTP ${res.status}, but the response body carries this load error after its config-adapter warnings, so caddy-mcp reports the load as failed. Caddy writes the warnings before it runs the load, which fixes the status at 200 whatever the load then does (caddyserver/caddy#7246); without warnings it answers the same failure with 400. Re-read the config to confirm what is running.`,
392
+ warnings: loadBody.warnings
393
+ };
394
+ }
263
395
  if (!socketPath && res.ok && isConfigChange(method, path)) await settleAdminRestart(getAdminOrigin());
264
396
  const text = res.text;
265
397
  const etag = res.etag;
266
398
  if (method === "GET" && etag && isConfigPath) {
267
- setEtag(path, etag);
399
+ if (isEchoableEtag(etag)) setEtag(path, etag);
400
+ else etagCache.delete(path);
268
401
  }
269
402
  if (isWrite && res.ok && isConfigPath) {
270
403
  invalidateRelated(path);
@@ -296,6 +429,7 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
296
429
  return { ok: false, status: res.status, error: text };
297
430
  }
298
431
  if (!text) return { ok: true, status: res.status, etag };
432
+ if (loadBody) return { ok: true, status: res.status, warnings: loadBody.warnings, etag };
299
433
  try {
300
434
  return { ok: true, status: res.status, data: JSON.parse(text), etag };
301
435
  } catch {
@@ -325,8 +459,16 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
325
459
  error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
326
460
  };
327
461
  }
328
- if (msg.includes("abort") || msg.includes("timeout")) {
329
- return { ok: false, status: 0, error: `Request timed out after ${effectiveTimeout}ms` };
462
+ if (isTimeoutError(err) || msg.includes("abort") || msg.includes("timeout") || msg.includes("timed out")) {
463
+ transport.timedOut = true;
464
+ const timedOutMsg = `Request timed out after ${effectiveTimeout}ms`;
465
+ if (!isConfigChange(method, path)) return { ok: false, status: 0, error: timedOutMsg };
466
+ return {
467
+ ok: false,
468
+ status: 0,
469
+ outcomeUnknown: true,
470
+ error: `${timedOutMsg} -- the outcome is unknown: Caddy may still be applying this change. A config change blocks until Caddy finishes reloading, and a client timeout does not cancel it, so it may have applied, may yet apply, or may not apply at all; caddy-mcp never replays a timed-out config change. Re-read the config before retrying. If reloads on this instance legitimately take this long, raise CADDY_LOAD_TIMEOUT, keeping it below your MCP client's request timeout (60 s by default in the MCP SDK), or this error never reaches the client.`
471
+ };
330
472
  }
331
473
  return { ok: false, status: 0, error: msg };
332
474
  }
@@ -372,20 +514,23 @@ function getRequestTimeout() {
372
514
  }
373
515
  function getLoadTimeout() {
374
516
  const raw = process.env.CADDY_LOAD_TIMEOUT;
375
- if (raw === void 0) return 6e4;
517
+ if (raw === void 0) return LOAD_TIMEOUT;
376
518
  const n = Number(raw);
377
- if (!Number.isFinite(n)) return 6e4;
519
+ if (!Number.isFinite(n)) return LOAD_TIMEOUT;
378
520
  const floored = Math.floor(n);
379
- if (floored < 1) return 6e4;
521
+ if (floored < 1) return LOAD_TIMEOUT;
380
522
  return floored;
381
523
  }
524
+ function getTimeoutFor(method, path) {
525
+ return isConfigChange(method, path) ? getLoadTimeout() : getRequestTimeout();
526
+ }
382
527
  async function loadConfig(config, contentType) {
383
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
528
+ const res = await caddyRequest("POST", "/load", config, contentType, true);
384
529
  if (res.ok) etagCache.clear();
385
530
  return res;
386
531
  }
387
532
  function adapt(config, adapter = "caddyfile") {
388
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
533
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, true);
389
534
  }
390
535
  function stop() {
391
536
  return caddyRequest("POST", "/stop");
@@ -439,28 +584,132 @@ function getMetrics() {
439
584
  import { z } from "zod";
440
585
 
441
586
  // src/format.ts
587
+ var WARNING_KEYS = /* @__PURE__ */ new Set(["file", "line", "directive", "message"]);
588
+ function formatWarning(w) {
589
+ const asJson = () => ` - ${JSON.stringify(w)}`;
590
+ if (w === null || typeof w !== "object" || Array.isArray(w)) return asJson();
591
+ const obj = w;
592
+ if (Object.keys(obj).some((key) => !WARNING_KEYS.has(key))) return asJson();
593
+ const { file, line, directive, message } = obj;
594
+ if (typeof message !== "string" || message === "") return asJson();
595
+ if (file !== void 0 && typeof file !== "string") return asJson();
596
+ if (line !== void 0 && typeof line !== "number") return asJson();
597
+ if (directive !== void 0 && typeof directive !== "string") return asJson();
598
+ const where = file && line !== void 0 ? `${file}:${line}` : file || (line !== void 0 ? `line ${line}` : "");
599
+ const prefix = [where, directive ? `(${directive})` : ""].filter(Boolean).join(" ");
600
+ return ` - ${prefix ? `${prefix}: ` : ""}${message}`;
601
+ }
602
+ function formatWarnings(warnings) {
603
+ if (!warnings || warnings.length === 0) return "";
604
+ return `
605
+
606
+ Adapter warnings (${warnings.length}):
607
+ ${warnings.map(formatWarning).join("\n")}`;
608
+ }
442
609
  function formatResult(res) {
610
+ const warnings = formatWarnings(res.warnings);
443
611
  if (!res.ok) {
444
612
  return {
445
613
  isError: true,
446
- content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
614
+ content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}${warnings}` }]
447
615
  };
448
616
  }
449
617
  const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
450
618
  const text = raw || "OK";
451
- return { content: [{ type: "text", text }] };
619
+ return { content: [{ type: "text", text: `${text}${warnings}` }] };
452
620
  }
453
621
 
454
622
  // src/tools/operational.ts
455
- var HTTPS_PORT_RE = /:443(?:\D|$)/;
456
- function describeServer(rawValue) {
623
+ var DEFAULT_HTTP_PORT = 80;
624
+ var DEFAULT_HTTPS_PORT = 443;
625
+ function appPort(value, fallback) {
626
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535 ? value : fallback;
627
+ }
628
+ function splitPort(hostport) {
629
+ const i = hostport.lastIndexOf(":");
630
+ if (i < 0) return void 0;
631
+ if (hostport.startsWith("[")) {
632
+ const end = hostport.indexOf("]");
633
+ if (end < 0 || end + 1 !== i) return void 0;
634
+ if (hostport.slice(1).includes("[") || hostport.slice(end + 1).includes("]")) return void 0;
635
+ } else {
636
+ if (hostport.slice(0, i).includes(":")) return void 0;
637
+ if (hostport.includes("[") || hostport.includes("]")) return void 0;
638
+ }
639
+ return hostport.slice(i + 1);
640
+ }
641
+ function parseListenPortRange(entry) {
642
+ let rest = entry;
643
+ const slash = entry.indexOf("/");
644
+ if (slash >= 0) {
645
+ const network = entry.slice(0, slash).trim().toLowerCase();
646
+ rest = entry.slice(slash + 1);
647
+ if (network.startsWith("unix") || network.startsWith("fd")) return { start: 0, end: 0 };
648
+ }
649
+ const port = splitPort(rest);
650
+ if (port === void 0 || port === "") return { start: 0, end: 0 };
651
+ const dash = port.indexOf("-");
652
+ const start = parseUint16(dash < 0 ? port : port.slice(0, dash));
653
+ const end = parseUint16(dash < 0 ? port : port.slice(dash + 1));
654
+ if (start === void 0 || end === void 0 || end < start) return void 0;
655
+ return { start, end };
656
+ }
657
+ function parseUint16(s) {
658
+ if (!/^[0-9]+$/.test(s)) return void 0;
659
+ const n = Number(s);
660
+ return n <= 65535 ? n : void 0;
661
+ }
662
+ function hasQualifyingHost(routes, skip) {
663
+ for (const route of routes) {
664
+ if (!route || typeof route !== "object") continue;
665
+ const matcherSets = route.match;
666
+ if (!Array.isArray(matcherSets)) continue;
667
+ for (const set of matcherSets) {
668
+ if (!set || typeof set !== "object") continue;
669
+ const hosts = set.host;
670
+ if (!Array.isArray(hosts)) continue;
671
+ for (const host of hosts) {
672
+ if (typeof host === "string" && !skip.has(host)) return true;
673
+ }
674
+ }
675
+ }
676
+ return false;
677
+ }
678
+ function describeServer(rawValue, httpPort = DEFAULT_HTTP_PORT, httpsPort = DEFAULT_HTTPS_PORT) {
457
679
  const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
458
680
  const listen = Array.isArray(raw.listen) ? raw.listen : [];
459
681
  const routes = Array.isArray(raw.routes) ? raw.routes : [];
682
+ const ranges = [];
683
+ for (const entry of listen) {
684
+ if (typeof entry !== "string") continue;
685
+ const range = parseListenPortRange(entry);
686
+ if (range) ranges.push(range);
687
+ }
688
+ const usesAnyPortOtherThan = (port) => ranges.some((r) => port > r.end || port < r.start);
689
+ const bindsPort = (port) => ranges.some((r) => r.start <= port && port <= r.end);
690
+ const autoHttps = raw.automatic_https !== null && typeof raw.automatic_https === "object" && !Array.isArray(raw.automatic_https) ? raw.automatic_https : {};
691
+ const skip = new Set(
692
+ Array.isArray(autoHttps.skip) ? autoHttps.skip.filter((s) => typeof s === "string") : []
693
+ );
694
+ const allSocketsOnHttpPort = (port) => ranges.length > 0 && ranges.every((r) => r.start === port && r.end === port);
695
+ const perListener = (label) => allSocketsOnHttpPort(httpPort) ? "enabled (no listener gets TLS: all are on the HTTP port)" : bindsPort(httpPort) ? "mixed (TLS on non-HTTP listeners only)" : label;
460
696
  const tlsPolicies = raw.tls_connection_policies;
461
- const hasExplicitTls = Array.isArray(tlsPolicies) ? tlsPolicies.length > 0 : !!tlsPolicies;
462
- const listensHttps = listen.some((l) => typeof l === "string" && HTTPS_PORT_RE.test(l));
463
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
697
+ let tls;
698
+ if (Array.isArray(tlsPolicies) && tlsPolicies.length === 0) {
699
+ tls = "off (empty tls_connection_policies)";
700
+ } else if (tlsPolicies) {
701
+ tls = perListener("enabled");
702
+ } else if (autoHttps.disable === true) {
703
+ tls = "off (automatic HTTPS disabled)";
704
+ } else if (!usesAnyPortOtherThan(httpPort)) {
705
+ tls = "off (HTTP only)";
706
+ } else if (!usesAnyPortOtherThan(httpsPort)) {
707
+ tls = perListener("auto (HTTPS)");
708
+ } else if (hasQualifyingHost(routes, skip)) {
709
+ tls = perListener("auto (HTTPS: host matchers on a non-HTTP port)");
710
+ } else {
711
+ tls = "off (no host matchers)";
712
+ }
464
713
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
465
714
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
466
715
  }
@@ -519,14 +768,17 @@ function registerOperationalTools(server) {
519
768
  const res = await configGet();
520
769
  if (!res.ok) return formatResult(res);
521
770
  const config = res.data ?? {};
522
- const servers = config.apps?.http?.servers ?? {};
771
+ const httpApp = config.apps?.http;
772
+ const servers = httpApp?.servers ?? {};
523
773
  const serverNames = Object.keys(servers);
774
+ const httpPort = appPort(httpApp?.http_port, DEFAULT_HTTP_PORT);
775
+ const httpsPort = appPort(httpApp?.https_port, DEFAULT_HTTPS_PORT);
524
776
  const lines = ["Caddy is running", ""];
525
777
  if (serverNames.length === 0) {
526
778
  lines.push("No HTTP servers configured");
527
779
  } else {
528
780
  for (const name of serverNames) {
529
- lines.push(`Server "${name}": ${describeServer(servers[name])}`);
781
+ lines.push(`Server "${name}": ${describeServer(servers[name], httpPort, httpsPort)}`);
530
782
  }
531
783
  }
532
784
  const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
@@ -560,7 +812,7 @@ ${lines.join("\n")}` }]
560
812
  );
561
813
  server.tool(
562
814
  "caddy_upstreams",
563
- "Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
815
+ "Caddy's /reverse_proxy/upstreams array (address, num_requests, fails), returned verbatim. On Caddy 2.11.2+ it is not the configured upstream list. Dynamic-upstream backends stay listed about 1 h (up to ~65 min) after the dynamic source last returned them, even after a config change removes them, so an address may appear that no current config references. A backend with requests in flight can appear twice when its resolved address differs from the entry's text (dynamic upstreams, tcp/ or unix// dials, placeholder dials). That extra copy always shows fails 0 and repeats num_requests, so do not sum num_requests across entries.",
564
816
  {},
565
817
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
566
818
  async () => formatResult(await getUpstreams())
@@ -634,7 +886,9 @@ function registerResources(server) {
634
886
  server.resource(
635
887
  "caddy-upstreams",
636
888
  "caddy://upstreams",
637
- { description: "Reverse proxy upstream health status" },
889
+ {
890
+ description: "Reverse proxy upstream health: Caddy's /reverse_proxy/upstreams array, returned verbatim. On Caddy 2.11.2+ it is not the configured upstream list -- dynamic upstreams stay listed about 1 h after the dynamic source last returned them (so an address can outlive the config that referenced it), and a backend with requests in flight can appear twice when its resolved address differs from the entry's text. The extra copy always shows fails 0 and repeats num_requests, so do not sum num_requests. See the caddy_upstreams tool for the full caveat."
891
+ },
638
892
  async () => {
639
893
  const res = await getUpstreams();
640
894
  return {
@@ -700,7 +954,7 @@ function registerResources(server) {
700
954
 
701
955
  // src/tools/adapt.ts
702
956
  import { z as z2 } from "zod";
703
- function formatWarning(w) {
957
+ function formatWarning2(w) {
704
958
  if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
705
959
  const obj = w;
706
960
  const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
@@ -710,7 +964,7 @@ function formatWarning(w) {
710
964
  function registerAdaptTools(server) {
711
965
  server.tool(
712
966
  "caddy_adapt",
713
- "Convert a config in any registered adapter format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces, or for porting from nginx/yaml configs when Caddy is built with the matching adapter module ('caddyfile' is built-in; 'nginx', 'yaml', etc. require their adapter modules to be compiled into the Caddy binary). Returns the adapted JSON and any warnings separately.",
967
+ `Convert a config in any registered adapter format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces, or for porting from nginx/yaml configs when Caddy is built with the matching adapter module ('caddyfile' is built-in; 'nginx', 'yaml', etc. require their adapter modules to be compiled into the Caddy binary). Returns the adapted JSON and any warnings separately. One exception to 'without loading it': on Caddy <= 2.11.4 a Caddyfile 'order' global option is not preview-only. It mutates that Caddy process's directive order, so it carries into every later Caddyfile adapt or load there, and an 'order' line that FAILS (unknown target, bad positional, extra arg) still removes the directive it names -- later Caddyfiles using that directive then fail with "directive 'X' is not an ordered HTTP handler" until another 'order' line re-places it or Caddy restarts. Fixed upstream in caddyserver/caddy#7995, unreleased as of v2.11.4.`,
714
968
  {
715
969
  config: z2.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
716
970
  adapter: z2.string().regex(/^[a-z0-9_-]+$/, "Adapter must be lowercase alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
@@ -726,7 +980,7 @@ function registerAdaptTools(server) {
726
980
  const result = data.result;
727
981
  const content = [];
728
982
  if (warnings.length > 0) {
729
- const warnLines = warnings.map(formatWarning);
983
+ const warnLines = warnings.map(formatWarning2);
730
984
  content.push({ type: "text", text: `Warnings:
731
985
  ${warnLines.join("\n")}` });
732
986
  }
@@ -820,6 +1074,9 @@ function getSnapshot(index) {
820
1074
  }
821
1075
 
822
1076
  // src/tools/config.ts
1077
+ function isRootConfigPath(path) {
1078
+ return /^\/*$/.test(path.replace(/^\/?(config(\/|$))?/, ""));
1079
+ }
823
1080
  function registerConfigTools(server) {
824
1081
  server.tool(
825
1082
  "caddy_config_get",
@@ -830,12 +1087,12 @@ function registerConfigTools(server) {
830
1087
  );
831
1088
  server.tool(
832
1089
  "caddy_config_set",
833
- "Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
1090
+ "Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' (POST) adds to an array \u2014 NOT idempotent: calling twice with the same route duplicates it \u2014 but on a non-array key it REPLACES whatever is there, and it cannot create missing parent objects. Mode 'insert' (PUT) inserts at an array index (useful for route ordering), or strictly creates an object key together with any missing parents and fails with 409 if the key already exists \u2014 the safe way to create a server or app, including on an instance with no config at all.",
834
1091
  {
835
1092
  path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
836
1093
  value: z3.any().describe("The JSON value to set at the path"),
837
1094
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
838
- "'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
1095
+ "'overwrite' = PATCH (replace existing, default, idempotent; 404 if the key does not exist), 'append' = POST (appends to arrays, NOT idempotent; REPLACES an existing non-array key; cannot create missing parents), 'insert' = PUT (inserts at an array index, or strictly creates an object key and any missing parents; 409 if the key exists)"
839
1096
  )
840
1097
  },
841
1098
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -846,7 +1103,7 @@ function registerConfigTools(server) {
846
1103
  );
847
1104
  server.tool(
848
1105
  "caddy_config_delete",
849
- "Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true.",
1106
+ "Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true. Any path that addresses the config ROOT ('', '/', 'config', '/config/', and slash-only variants of those) addresses the ENTIRE config and unloads it: every app and server goes, and so does the 'admin' block, after which Caddy re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment). If CADDY_ADMIN_URL points anywhere else, neither this server nor caddy_revert can reach Caddy afterwards. A root delete is snapshotted first, so caddy_revert can restore it while Caddy is still reachable; no other path is snapshotted. To REPLACE the config rather than unload it, use caddy_load.",
850
1107
  {
851
1108
  path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
852
1109
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
@@ -857,27 +1114,54 @@ function registerConfigTools(server) {
857
1114
  // array after a delete, so repeating that call removes a DIFFERENT route each
858
1115
  // time. caddy_remove_route carries the same correction for the byte-identical
859
1116
  // underlying request; the two must agree. Nothing here is auto-recoverable
860
- // either: only caddy_load captures a snapshot, so a spurious repeat cannot be
861
- // undone with caddy_revert.
1117
+ // either: apart from a root delete (below), only caddy_load captures a
1118
+ // snapshot, so a spurious repeat cannot be undone with caddy_revert.
862
1119
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
863
1120
  async ({ path, confirm }) => {
1121
+ const root = isRootConfigPath(path);
864
1122
  if (!confirm) {
865
1123
  return {
866
1124
  isError: true,
867
1125
  content: [
868
1126
  {
869
1127
  type: "text",
870
- text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
1128
+ text: root ? `Refusing to delete the ENTIRE config without confirm=true. The path "${path}" addresses the config root, so this unloads every app and server, and the 'admin' block with them: Caddy then re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment), and if CADDY_ADMIN_URL points anywhere else neither this server nor caddy_revert can reach Caddy afterwards. The current config is snapshotted first, so caddy_revert can restore it while Caddy is still reachable. To REPLACE the config rather than unload it, use caddy_load. Re-run with confirm:true to proceed.` : `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
871
1129
  }
872
1130
  ]
873
1131
  };
874
1132
  }
875
- return formatResult(await configDelete(path));
1133
+ if (!root) return formatResult(await configDelete(path));
1134
+ const current = await configGet();
1135
+ const res = await configDelete("");
1136
+ const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1137
+ if (kept) saveSnapshot(current.data, "caddy_config_delete");
1138
+ if (!res.ok) {
1139
+ if (res.outcomeUnknown && kept) {
1140
+ return formatResult({
1141
+ ...res,
1142
+ error: `${res.error}
1143
+ The pre-delete config was kept anyway, as snapshot [0] (trigger=caddy_config_delete): if this delete did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it unloaded -- as long as this server can still reach Caddy's admin endpoint, which moves back to Caddy's default address when the deleted config set an admin.listen of its own. If the delete did not apply, that snapshot is simply the config read just before it was sent.`
1144
+ });
1145
+ }
1146
+ return formatResult(res);
1147
+ }
1148
+ const note = kept ? ` The prior config was saved as snapshot [0] (trigger=caddy_config_delete): caddy_revert { action: "apply", index: 0, confirm: true } restores it.` : current.ok ? " Warning: the prior config was empty or not a JSON object, so no snapshot was captured -- there was nothing to restore." : " Warning: the prior config could not be read, so no snapshot was captured and this unload cannot be reverted.";
1149
+ const adm = kept ? current.data.admin : void 0;
1150
+ const listen = adm !== null && typeof adm === "object" && !Array.isArray(adm) ? adm.listen : void 0;
1151
+ const adminNote = typeof listen === "string" && listen !== "" ? ` The unloaded config set admin.listen to "${listen}". If that was not already Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.` : "";
1152
+ return {
1153
+ content: [
1154
+ {
1155
+ type: "text",
1156
+ text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
1157
+ }
1158
+ ]
1159
+ };
876
1160
  }
877
1161
  );
878
1162
  server.tool(
879
1163
  "caddy_load",
880
- "Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
1164
+ "Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Runs on CADDY_LOAD_TIMEOUT (55 s by default), like every config change; a load that times out is not retried and may still apply, so re-read the config before loading again. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
881
1165
  {
882
1166
  config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
883
1167
  format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
@@ -899,15 +1183,21 @@ function registerConfigTools(server) {
899
1183
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
900
1184
  const current = await configGet();
901
1185
  const res = await loadConfig(config, contentType);
902
- if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
903
- saveSnapshot(current.data, "caddy_load");
1186
+ const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1187
+ if (kept) saveSnapshot(current.data, "caddy_load");
1188
+ if (res.outcomeUnknown && kept) {
1189
+ return formatResult({
1190
+ ...res,
1191
+ error: `${res.error}
1192
+ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if this load did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it replaced. If it did not, that snapshot is simply the config read just before the load was sent.`
1193
+ });
904
1194
  }
905
1195
  return formatResult(res);
906
1196
  }
907
1197
  );
908
1198
  server.tool(
909
1199
  "caddy_revert",
910
- "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (last 10). By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
1200
+ "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete that unloads the whole config (an empty path); no other delete is snapshotted. Last 10. By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
911
1201
  {
912
1202
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
913
1203
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -970,7 +1260,19 @@ ${lines.join("\n")}` }] };
970
1260
  }
971
1261
  const current = await configGet();
972
1262
  const res = await loadConfig(snap.config, "application/json");
973
- if (!res.ok) return formatResult(res);
1263
+ if (!res.ok) {
1264
+ if (res.outcomeUnknown && current.ok && isSnapshotableConfig(current.data)) {
1265
+ saveSnapshot(current.data, "caddy_revert");
1266
+ const now = listSnapshots().indexOf(snap);
1267
+ const target = now === -1 ? `the snapshot you asked to apply ([${index}]) has dropped out of the ring` : `the snapshot you asked to apply is now [${now}]`;
1268
+ return formatResult({
1269
+ ...res,
1270
+ error: `${res.error}
1271
+ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), so this revert can still be rolled back if it did apply. That moved every older snapshot down one index: ${target}, so re-running apply with index ${index} would load a different snapshot. List the snapshots before retrying.`
1272
+ });
1273
+ }
1274
+ return formatResult(res);
1275
+ }
974
1276
  const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
975
1277
  if (capturedRollforward) {
976
1278
  saveSnapshot(current.data, "caddy_revert");
@@ -994,7 +1296,7 @@ ${lines.join("\n")}` }] };
994
1296
  value: z3.any().optional().describe("New value (required for 'set' action)"),
995
1297
  subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
996
1298
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
997
- "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
1299
+ "For 'set' action: 'overwrite' = PATCH (replace the identified object, or the value at subpath; default). 'append' = POST and 'insert' = PUT behave as in caddy_config_set at the resolved path: with a subpath into an array, POST appends and PUT inserts at the index; PUT also strictly creates an object key (409 if it exists). With NO subpath: for an array element (a route) neither replaces it \u2014 POST adds the value as a new element at the end of that array, PUT inserts it just before the identified one, and both are rejected with 'duplicate ID' if the value carries the same @id; for an object held under a key (a server) POST REPLACES it wholesale and PUT fails with 409. Use 'overwrite' to replace in place."
998
1300
  ),
999
1301
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
1000
1302
  },
@@ -1150,7 +1452,7 @@ function serverNotFoundError(srv, op = "operation") {
1150
1452
  content: [
1151
1453
  {
1152
1454
  type: "text",
1153
- 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.`
1455
+ 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: "insert", value: { "listen": [":443"], "routes": [] } }. Both arguments are load-bearing: mode "insert" (PUT) creates the key along with any missing apps/http/servers parents, so it works even on an instance with no config at all, and fails with 409 if the server already exists -- whereas "append" (POST) would replace an existing server and cannot create missing parents, and 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.`
1154
1456
  }
1155
1457
  ]
1156
1458
  };
@@ -1161,7 +1463,7 @@ function serverNullError(srv) {
1161
1463
  content: [
1162
1464
  {
1163
1465
  type: "text",
1164
- 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"] }`
1466
+ 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. To create this one: caddy_config_set { path: "apps/http/servers/${srv}", mode: "insert", value: { "listen": [":443"], "routes": [] } }. Mode "insert" (PUT) strictly creates the key, so it never overwrites anything. If it fails with 409 "key already exists", the key is there now: it may hold a null config, or it may have been created after this read (by another writer, or by an earlier attempt of this same write whose response was lost). Re-read it with caddy_config_get { path: "apps/http/servers/${srv}" }, and use mode "overwrite" only if that read still shows null -- overwrite (PATCH) replaces the whole server, routes included.`
1165
1467
  }
1166
1468
  ]
1167
1469
  };
@@ -1544,7 +1846,7 @@ function buildTlsConfig(fields) {
1544
1846
  }
1545
1847
  };
1546
1848
  }
1547
- function bothErrors(label, patchRes, writeRes, writeLabel) {
1849
+ function bothErrors(label, patchRes, writeRes, writeLabel, hint) {
1548
1850
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
1549
1851
  const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
1550
1852
  return {
@@ -1554,11 +1856,16 @@ function bothErrors(label, patchRes, writeRes, writeLabel) {
1554
1856
  type: "text",
1555
1857
  text: `Error: Failed to set ${label}.
1556
1858
  PATCH attempt: ${patchErr}
1557
- ${writeLabel} fallback: ${writeErr}`
1859
+ ${writeLabel} fallback: ${writeErr}` + (hint ? `
1860
+ ${hint}` : "")
1558
1861
  }
1559
1862
  ]
1560
1863
  };
1561
1864
  }
1865
+ function isAbsentOnGet(res) {
1866
+ if (res.ok) return res.data === void 0 || res.data === null;
1867
+ return isMissingConfigPath(res);
1868
+ }
1562
1869
  function isPlainObject(v) {
1563
1870
  return typeof v === "object" && v !== null && !Array.isArray(v);
1564
1871
  }
@@ -1621,13 +1928,15 @@ function refuseFallback(label, patchRes, detail) {
1621
1928
  }
1622
1929
  };
1623
1930
  }
1931
+ var CREATE_CONFLICT_HINT = "apps/tls read as not set, but Caddy now reports the key exists, so nothing was overwritten. Either something else created it between the read and this write, or an earlier attempt of this same write landed and only its response was lost. Check caddy_tls status, then re-run this action so it merges into what is there instead of creating it.";
1624
1932
  async function safeFallback(label, patchRes, fields) {
1625
1933
  const getRes = await configGet("apps/tls");
1626
- const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1934
+ const absent = isAbsentOnGet(getRes);
1627
1935
  if (absent) {
1628
- const postRes = await configPost("apps/tls", buildTlsConfig(fields));
1629
- if (postRes.ok) return { kind: "ok" };
1630
- return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
1936
+ const putRes = await configPut("apps/tls", buildTlsConfig(fields));
1937
+ if (putRes.ok) return { kind: "ok" };
1938
+ const hint = putRes.status === 409 ? CREATE_CONFLICT_HINT : void 0;
1939
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT", hint) };
1631
1940
  }
1632
1941
  if (!getRes.ok) {
1633
1942
  return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
@@ -1669,29 +1978,39 @@ function missingArgError(text) {
1669
1978
  function registerTlsTools(server) {
1670
1979
  server.tool(
1671
1980
  "caddy_tls",
1672
- "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.",
1981
+ "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 at apps/tls/encrypted_client_hello (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances, including one with no config at all: the set_* actions create apps/tls, and any missing parents, when it is not set. 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.",
1673
1982
  {
1674
1983
  action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1675
1984
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
1676
1985
  ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
1677
1986
  profile: z5.string().optional().describe(
1678
- "ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy."
1987
+ "ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy. EXPERIMENTAL upstream (the ACME profiles spec is still a draft; Caddy marks the field 'subject to change' and may rename or drop it). Caddy accepts any name on load, so a success here does not mean the CA offers it: if this issuer's CA does not advertise the name, every order from this issuer fails at issuance time, reported only in Caddy's own logs. Caddy then either falls through to the next issuer in the policy, which issues WITHOUT the profile, or -- when this is the policy's only issuer, which is the shape this tool creates -- keeps retrying and issues no certificate at all."
1679
1988
  )
1680
1989
  },
1681
1990
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1682
1991
  async ({ action, email, ca, profile }) => {
1683
1992
  if (action === "status") {
1684
- return formatResult(await configGet("apps/tls"));
1993
+ const tlsRes = await configGet("apps/tls");
1994
+ if (isAbsentOnGet(tlsRes)) {
1995
+ return {
1996
+ content: [
1997
+ {
1998
+ type: "text",
1999
+ text: "No TLS config is set on this instance (apps/tls is not set), so Caddy's TLS defaults apply. The set_email / set_acme_ca / set_acme_profile actions create it."
2000
+ }
2001
+ ]
2002
+ };
2003
+ }
2004
+ return formatResult(tlsRes);
1685
2005
  }
1686
2006
  if (action === "ech_status") {
1687
- const echRes = await configGet("apps/tls/ech");
1688
- const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
1689
- if (absent) {
2007
+ const echRes = await configGet("apps/tls/encrypted_client_hello");
2008
+ if (isAbsentOnGet(echRes)) {
1690
2009
  return {
1691
2010
  content: [
1692
2011
  {
1693
2012
  type: "text",
1694
- text: "ECH (Encrypted ClientHello) is not configured on this instance. Requires Caddy 2.10+; enable it by applying a config with apps/tls/ech via caddy_load."
2013
+ text: "ECH (Encrypted ClientHello) is not configured on this instance: apps/tls/encrypted_client_hello is not set. Requires Caddy 2.10+; enable it by applying a config that sets apps.tls.encrypted_client_hello via caddy_load. The JSON key is 'encrypted_client_hello' -- 'ech' is only the Caddyfile global option name, and Caddy rejects apps.tls.ech as an unknown field."
1695
2014
  }
1696
2015
  ]
1697
2016
  };