@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/index.js CHANGED
@@ -14,6 +14,7 @@ var RETRY_MAX_DELAY_MS = 2e3;
14
14
  var RETRY_MAX_JITTER_MS = 50;
15
15
  var RETRY_HARD_CAP = 5;
16
16
  var ADMIN_RESTART_SETTLE_MS = 250;
17
+ var LOAD_TIMEOUT = 55e3;
17
18
  var etagCache = /* @__PURE__ */ new Map();
18
19
  var MAX_ETAG_CACHE = 256;
19
20
  function setEtag(path, etag) {
@@ -23,6 +24,54 @@ function setEtag(path, etag) {
23
24
  }
24
25
  etagCache.set(path, etag);
25
26
  }
27
+ var GO_WHITESPACE = /* @__PURE__ */ new Set([
28
+ 9,
29
+ 10,
30
+ 11,
31
+ 12,
32
+ 13,
33
+ 32,
34
+ 133,
35
+ 160,
36
+ 5760,
37
+ 8192,
38
+ 8193,
39
+ 8194,
40
+ 8195,
41
+ 8196,
42
+ 8197,
43
+ 8198,
44
+ 8199,
45
+ 8200,
46
+ 8201,
47
+ 8202,
48
+ 8232,
49
+ 8233,
50
+ 8239,
51
+ 8287,
52
+ 12288
53
+ ]);
54
+ function goFields(text) {
55
+ const fields = [];
56
+ let current = "";
57
+ for (const ch of text) {
58
+ if (GO_WHITESPACE.has(ch.codePointAt(0))) {
59
+ if (current) fields.push(current);
60
+ current = "";
61
+ } else {
62
+ current += ch;
63
+ }
64
+ }
65
+ if (current) fields.push(current);
66
+ return fields;
67
+ }
68
+ function isEchoableEtag(etag) {
69
+ const decoded = Buffer.from(etag, "latin1").toString("utf8");
70
+ if (decoded.length < 2 || !decoded.startsWith('"') || !decoded.endsWith('"')) return false;
71
+ const inner = decoded.slice(1, -1);
72
+ const fields = goFields(inner);
73
+ return fields.length === 2 && fields.join(" ") === inner;
74
+ }
26
75
  function isAncestorOf(ancestor, descendant) {
27
76
  const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
28
77
  return descendant.startsWith(`${base}/`);
@@ -108,25 +157,36 @@ function sleep(ms) {
108
157
  function isTransientFailure(res) {
109
158
  if (res.ok) return false;
110
159
  if (res.status === 0) return true;
111
- if (res.status >= 500 && res.status <= 599) return true;
112
- return false;
160
+ return res.status === 502 || res.status === 503 || res.status === 504;
113
161
  }
114
162
  function isMissingConfigPath(res) {
115
163
  if (res.ok) return false;
116
164
  return (res.error ?? "").toLowerCase().includes("invalid traversal path");
117
165
  }
118
- var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
166
+ var ARRAY_INDEX_TAIL_RE = /\/\d+(\/+\.\.\.)?\/*$/;
167
+ var BARE_ID_RE = /^\/id\/[^/]+(\/+\.\.\.)?\/*$/;
168
+ function isConfigChange(method, path) {
169
+ if (method === "GET") return false;
170
+ return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
171
+ }
119
172
  function isRetryableMethod(method, path) {
120
- if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
173
+ if (method === "PUT" && BARE_ID_RE.test(path)) return false;
174
+ if (method === "PUT" || method === "DELETE") return !ARRAY_INDEX_TAIL_RE.test(path);
121
175
  if (method !== "POST") return true;
122
176
  return !path.startsWith("/config/") && !path.startsWith("/id/");
123
177
  }
178
+ function shouldRetry(method, path, attempt) {
179
+ if (!isTransientFailure(attempt.res)) return false;
180
+ if (attempt.refused) return true;
181
+ if (attempt.timedOut && isConfigChange(method, path)) return false;
182
+ return isRetryableMethod(method, path);
183
+ }
124
184
  function getMalformedUnixUrl() {
125
185
  const raw = (process.env.CADDY_ADMIN_URL || "").trim();
126
186
  if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
127
187
  return getUnixSocketPath() === void 0 ? raw : void 0;
128
188
  }
129
- async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
189
+ async function caddyRequest(method, path, body, contentType, rawStringBody = false) {
130
190
  const malformed = getMalformedUnixUrl();
131
191
  if (malformed) {
132
192
  return {
@@ -136,16 +196,16 @@ async function caddyRequest(method, path, body, contentType, timeout, rawStringB
136
196
  };
137
197
  }
138
198
  const maxRetries = getMaxRetries();
139
- let attempt = 0;
140
- let { res, refused } = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
141
- while (isTransientFailure(res) && (refused || isRetryableMethod(method, path)) && attempt < maxRetries) {
142
- attempt++;
143
- const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
199
+ let retries = 0;
200
+ let attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
201
+ while (retries < maxRetries && shouldRetry(method, path, attempt)) {
202
+ retries++;
203
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
144
204
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
145
205
  await sleep(delay);
146
- ({ res, refused } = await attemptRequest(method, path, body, contentType, timeout, rawStringBody));
206
+ attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
147
207
  }
148
- return res;
208
+ return attempt.res;
149
209
  }
150
210
  function isConnectionRefused(err) {
151
211
  let current = err;
@@ -159,27 +219,44 @@ function isConnectionRefused(err) {
159
219
  }
160
220
  return false;
161
221
  }
222
+ function isTimeoutError(err) {
223
+ let current = err;
224
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
225
+ const e = current;
226
+ if (e.name === "TimeoutError" || e.name === "AbortError") return true;
227
+ current = e.cause;
228
+ }
229
+ return false;
230
+ }
162
231
  function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
163
232
  return new Promise((resolve, reject) => {
164
- const req = httpRequest(
165
- { socketPath, path, method, headers, agent: false, signal: AbortSignal.timeout(timeoutMs) },
166
- (res) => {
167
- const chunks = [];
168
- res.on("data", (chunk) => chunks.push(chunk));
169
- res.on("error", reject);
170
- res.on("end", () => {
171
- const status = res.statusCode ?? 0;
172
- const etag = res.headers.etag;
173
- resolve({
174
- ok: status >= 200 && status < 300,
175
- status,
176
- text: Buffer.concat(chunks).toString("utf8"),
177
- etag: typeof etag === "string" ? etag : void 0
178
- });
233
+ let deadlineHit = false;
234
+ const deadline = Object.assign(new Error(`timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
235
+ function fail(err) {
236
+ clearTimeout(timer);
237
+ reject(deadlineHit ? deadline : err);
238
+ }
239
+ const req = httpRequest({ socketPath, path, method, headers, agent: false }, (res) => {
240
+ const chunks = [];
241
+ res.on("data", (chunk) => chunks.push(chunk));
242
+ res.on("error", fail);
243
+ res.on("end", () => {
244
+ clearTimeout(timer);
245
+ const status = res.statusCode ?? 0;
246
+ const etag = res.headers.etag;
247
+ resolve({
248
+ ok: status >= 200 && status < 300,
249
+ status,
250
+ text: Buffer.concat(chunks).toString("utf8"),
251
+ etag: typeof etag === "string" ? etag : void 0
179
252
  });
180
- }
181
- );
182
- req.on("error", reject);
253
+ });
254
+ });
255
+ const timer = setTimeout(() => {
256
+ deadlineHit = true;
257
+ req.destroy(deadline);
258
+ }, timeoutMs);
259
+ req.on("error", fail);
183
260
  if (body !== void 0) req.write(body);
184
261
  req.end();
185
262
  });
@@ -238,19 +315,65 @@ function settleAdminRestart(origin) {
238
315
  socketWaiters.add(check);
239
316
  });
240
317
  }
241
- function isConfigChange(method, path) {
242
- if (method === "GET") return false;
243
- return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
318
+ function endOfFirstJsonValue(text) {
319
+ let depth = 0;
320
+ let inString = false;
321
+ let escaped = false;
322
+ for (let i = 0; i < text.length; i++) {
323
+ const ch = text[i];
324
+ if (inString) {
325
+ if (escaped) escaped = false;
326
+ else if (ch === "\\") escaped = true;
327
+ else if (ch === '"') inString = false;
328
+ continue;
329
+ }
330
+ if (ch === '"') inString = true;
331
+ else if (ch === "[" || ch === "{") depth++;
332
+ else if (ch === "]" || ch === "}") {
333
+ depth--;
334
+ if (depth <= 0) return depth === 0 ? i + 1 : -1;
335
+ }
336
+ }
337
+ return -1;
338
+ }
339
+ function parseJsonOrUndefined(text) {
340
+ try {
341
+ return JSON.parse(text);
342
+ } catch {
343
+ return void 0;
344
+ }
244
345
  }
245
- async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
246
- const transport = { refused: false };
247
- const res = await sendOnce(transport, method, path, body, contentType, timeout, rawStringBody);
248
- return { res, refused: transport.refused };
346
+ function readLoadBody(text) {
347
+ const body = text.trim();
348
+ if (body.startsWith("[")) {
349
+ const end = endOfFirstJsonValue(body);
350
+ if (end === -1) return void 0;
351
+ const warnings = parseJsonOrUndefined(body.slice(0, end));
352
+ if (!Array.isArray(warnings)) return void 0;
353
+ const tail = body.slice(end).trim();
354
+ if (!tail) return { warnings };
355
+ const trailing = parseJsonOrUndefined(tail);
356
+ if (trailing === null || typeof trailing !== "object" || Array.isArray(trailing)) return void 0;
357
+ if (typeof trailing.error !== "string") return void 0;
358
+ return { warnings, errorText: tail };
359
+ }
360
+ if (body.startsWith("{")) {
361
+ const parsed = parseJsonOrUndefined(body);
362
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
363
+ const warnings = parsed.warnings;
364
+ if (Array.isArray(warnings) && Object.keys(parsed).length === 1) return { warnings };
365
+ }
366
+ return void 0;
249
367
  }
250
- async function sendOnce(transport, method, path, body, contentType, timeout, rawStringBody = false) {
368
+ async function attemptRequest(method, path, body, contentType, rawStringBody = false) {
369
+ const transport = { refused: false, timedOut: false };
370
+ const res = await sendOnce(transport, method, path, body, contentType, rawStringBody);
371
+ return { res, refused: transport.refused, timedOut: transport.timedOut };
372
+ }
373
+ async function sendOnce(transport, method, path, body, contentType, rawStringBody = false) {
251
374
  const socketPath = getUnixSocketPath();
252
375
  const url = `${getBaseUrl()}${path}`;
253
- const effectiveTimeout = timeout ?? getRequestTimeout();
376
+ const effectiveTimeout = getTimeoutFor(method, path);
254
377
  try {
255
378
  const hasBody = body !== void 0;
256
379
  const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
@@ -262,11 +385,21 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
262
385
  }
263
386
  const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
264
387
  const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
388
+ const loadBody = path === "/load" && res.ok ? readLoadBody(res.text) : void 0;
389
+ if (loadBody?.errorText !== void 0) {
390
+ return {
391
+ ok: false,
392
+ status: res.status,
393
+ 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.`,
394
+ warnings: loadBody.warnings
395
+ };
396
+ }
265
397
  if (!socketPath && res.ok && isConfigChange(method, path)) await settleAdminRestart(getAdminOrigin());
266
398
  const text = res.text;
267
399
  const etag = res.etag;
268
400
  if (method === "GET" && etag && isConfigPath) {
269
- setEtag(path, etag);
401
+ if (isEchoableEtag(etag)) setEtag(path, etag);
402
+ else etagCache.delete(path);
270
403
  }
271
404
  if (isWrite && res.ok && isConfigPath) {
272
405
  invalidateRelated(path);
@@ -298,6 +431,7 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
298
431
  return { ok: false, status: res.status, error: text };
299
432
  }
300
433
  if (!text) return { ok: true, status: res.status, etag };
434
+ if (loadBody) return { ok: true, status: res.status, warnings: loadBody.warnings, etag };
301
435
  try {
302
436
  return { ok: true, status: res.status, data: JSON.parse(text), etag };
303
437
  } catch {
@@ -327,8 +461,16 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
327
461
  error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
328
462
  };
329
463
  }
330
- if (msg.includes("abort") || msg.includes("timeout")) {
331
- return { ok: false, status: 0, error: `Request timed out after ${effectiveTimeout}ms` };
464
+ if (isTimeoutError(err) || msg.includes("abort") || msg.includes("timeout") || msg.includes("timed out")) {
465
+ transport.timedOut = true;
466
+ const timedOutMsg = `Request timed out after ${effectiveTimeout}ms`;
467
+ if (!isConfigChange(method, path)) return { ok: false, status: 0, error: timedOutMsg };
468
+ return {
469
+ ok: false,
470
+ status: 0,
471
+ outcomeUnknown: true,
472
+ 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.`
473
+ };
332
474
  }
333
475
  return { ok: false, status: 0, error: msg };
334
476
  }
@@ -374,20 +516,23 @@ function getRequestTimeout() {
374
516
  }
375
517
  function getLoadTimeout() {
376
518
  const raw = process.env.CADDY_LOAD_TIMEOUT;
377
- if (raw === void 0) return 6e4;
519
+ if (raw === void 0) return LOAD_TIMEOUT;
378
520
  const n = Number(raw);
379
- if (!Number.isFinite(n)) return 6e4;
521
+ if (!Number.isFinite(n)) return LOAD_TIMEOUT;
380
522
  const floored = Math.floor(n);
381
- if (floored < 1) return 6e4;
523
+ if (floored < 1) return LOAD_TIMEOUT;
382
524
  return floored;
383
525
  }
526
+ function getTimeoutFor(method, path) {
527
+ return isConfigChange(method, path) ? getLoadTimeout() : getRequestTimeout();
528
+ }
384
529
  async function loadConfig(config, contentType) {
385
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
530
+ const res = await caddyRequest("POST", "/load", config, contentType, true);
386
531
  if (res.ok) etagCache.clear();
387
532
  return res;
388
533
  }
389
534
  function adapt(config, adapter = "caddyfile") {
390
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
535
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, true);
391
536
  }
392
537
  function stop() {
393
538
  return caddyRequest("POST", "/stop");
@@ -441,28 +586,132 @@ function getMetrics() {
441
586
  import { z } from "zod";
442
587
 
443
588
  // src/format.ts
589
+ var WARNING_KEYS = /* @__PURE__ */ new Set(["file", "line", "directive", "message"]);
590
+ function formatWarning(w) {
591
+ const asJson = () => ` - ${JSON.stringify(w)}`;
592
+ if (w === null || typeof w !== "object" || Array.isArray(w)) return asJson();
593
+ const obj = w;
594
+ if (Object.keys(obj).some((key) => !WARNING_KEYS.has(key))) return asJson();
595
+ const { file, line, directive, message } = obj;
596
+ if (typeof message !== "string" || message === "") return asJson();
597
+ if (file !== void 0 && typeof file !== "string") return asJson();
598
+ if (line !== void 0 && typeof line !== "number") return asJson();
599
+ if (directive !== void 0 && typeof directive !== "string") return asJson();
600
+ const where = file && line !== void 0 ? `${file}:${line}` : file || (line !== void 0 ? `line ${line}` : "");
601
+ const prefix = [where, directive ? `(${directive})` : ""].filter(Boolean).join(" ");
602
+ return ` - ${prefix ? `${prefix}: ` : ""}${message}`;
603
+ }
604
+ function formatWarnings(warnings) {
605
+ if (!warnings || warnings.length === 0) return "";
606
+ return `
607
+
608
+ Adapter warnings (${warnings.length}):
609
+ ${warnings.map(formatWarning).join("\n")}`;
610
+ }
444
611
  function formatResult(res) {
612
+ const warnings = formatWarnings(res.warnings);
445
613
  if (!res.ok) {
446
614
  return {
447
615
  isError: true,
448
- content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
616
+ content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}${warnings}` }]
449
617
  };
450
618
  }
451
619
  const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
452
620
  const text = raw || "OK";
453
- return { content: [{ type: "text", text }] };
621
+ return { content: [{ type: "text", text: `${text}${warnings}` }] };
454
622
  }
455
623
 
456
624
  // src/tools/operational.ts
457
- var HTTPS_PORT_RE = /:443(?:\D|$)/;
458
- function describeServer(rawValue) {
625
+ var DEFAULT_HTTP_PORT = 80;
626
+ var DEFAULT_HTTPS_PORT = 443;
627
+ function appPort(value, fallback) {
628
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535 ? value : fallback;
629
+ }
630
+ function splitPort(hostport) {
631
+ const i = hostport.lastIndexOf(":");
632
+ if (i < 0) return void 0;
633
+ if (hostport.startsWith("[")) {
634
+ const end = hostport.indexOf("]");
635
+ if (end < 0 || end + 1 !== i) return void 0;
636
+ if (hostport.slice(1).includes("[") || hostport.slice(end + 1).includes("]")) return void 0;
637
+ } else {
638
+ if (hostport.slice(0, i).includes(":")) return void 0;
639
+ if (hostport.includes("[") || hostport.includes("]")) return void 0;
640
+ }
641
+ return hostport.slice(i + 1);
642
+ }
643
+ function parseListenPortRange(entry) {
644
+ let rest = entry;
645
+ const slash = entry.indexOf("/");
646
+ if (slash >= 0) {
647
+ const network = entry.slice(0, slash).trim().toLowerCase();
648
+ rest = entry.slice(slash + 1);
649
+ if (network.startsWith("unix") || network.startsWith("fd")) return { start: 0, end: 0 };
650
+ }
651
+ const port = splitPort(rest);
652
+ if (port === void 0 || port === "") return { start: 0, end: 0 };
653
+ const dash = port.indexOf("-");
654
+ const start = parseUint16(dash < 0 ? port : port.slice(0, dash));
655
+ const end = parseUint16(dash < 0 ? port : port.slice(dash + 1));
656
+ if (start === void 0 || end === void 0 || end < start) return void 0;
657
+ return { start, end };
658
+ }
659
+ function parseUint16(s) {
660
+ if (!/^[0-9]+$/.test(s)) return void 0;
661
+ const n = Number(s);
662
+ return n <= 65535 ? n : void 0;
663
+ }
664
+ function hasQualifyingHost(routes, skip) {
665
+ for (const route of routes) {
666
+ if (!route || typeof route !== "object") continue;
667
+ const matcherSets = route.match;
668
+ if (!Array.isArray(matcherSets)) continue;
669
+ for (const set of matcherSets) {
670
+ if (!set || typeof set !== "object") continue;
671
+ const hosts = set.host;
672
+ if (!Array.isArray(hosts)) continue;
673
+ for (const host of hosts) {
674
+ if (typeof host === "string" && !skip.has(host)) return true;
675
+ }
676
+ }
677
+ }
678
+ return false;
679
+ }
680
+ function describeServer(rawValue, httpPort = DEFAULT_HTTP_PORT, httpsPort = DEFAULT_HTTPS_PORT) {
459
681
  const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
460
682
  const listen = Array.isArray(raw.listen) ? raw.listen : [];
461
683
  const routes = Array.isArray(raw.routes) ? raw.routes : [];
684
+ const ranges = [];
685
+ for (const entry of listen) {
686
+ if (typeof entry !== "string") continue;
687
+ const range = parseListenPortRange(entry);
688
+ if (range) ranges.push(range);
689
+ }
690
+ const usesAnyPortOtherThan = (port) => ranges.some((r) => port > r.end || port < r.start);
691
+ const bindsPort = (port) => ranges.some((r) => r.start <= port && port <= r.end);
692
+ const autoHttps = raw.automatic_https !== null && typeof raw.automatic_https === "object" && !Array.isArray(raw.automatic_https) ? raw.automatic_https : {};
693
+ const skip = new Set(
694
+ Array.isArray(autoHttps.skip) ? autoHttps.skip.filter((s) => typeof s === "string") : []
695
+ );
696
+ const allSocketsOnHttpPort = (port) => ranges.length > 0 && ranges.every((r) => r.start === port && r.end === port);
697
+ 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;
462
698
  const tlsPolicies = raw.tls_connection_policies;
463
- const hasExplicitTls = Array.isArray(tlsPolicies) ? tlsPolicies.length > 0 : !!tlsPolicies;
464
- const listensHttps = listen.some((l) => typeof l === "string" && HTTPS_PORT_RE.test(l));
465
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
699
+ let tls;
700
+ if (Array.isArray(tlsPolicies) && tlsPolicies.length === 0) {
701
+ tls = "off (empty tls_connection_policies)";
702
+ } else if (tlsPolicies) {
703
+ tls = perListener("enabled");
704
+ } else if (autoHttps.disable === true) {
705
+ tls = "off (automatic HTTPS disabled)";
706
+ } else if (!usesAnyPortOtherThan(httpPort)) {
707
+ tls = "off (HTTP only)";
708
+ } else if (!usesAnyPortOtherThan(httpsPort)) {
709
+ tls = perListener("auto (HTTPS)");
710
+ } else if (hasQualifyingHost(routes, skip)) {
711
+ tls = perListener("auto (HTTPS: host matchers on a non-HTTP port)");
712
+ } else {
713
+ tls = "off (no host matchers)";
714
+ }
466
715
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
467
716
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
468
717
  }
@@ -521,14 +770,17 @@ function registerOperationalTools(server) {
521
770
  const res = await configGet();
522
771
  if (!res.ok) return formatResult(res);
523
772
  const config = res.data ?? {};
524
- const servers = config.apps?.http?.servers ?? {};
773
+ const httpApp = config.apps?.http;
774
+ const servers = httpApp?.servers ?? {};
525
775
  const serverNames = Object.keys(servers);
776
+ const httpPort = appPort(httpApp?.http_port, DEFAULT_HTTP_PORT);
777
+ const httpsPort = appPort(httpApp?.https_port, DEFAULT_HTTPS_PORT);
526
778
  const lines = ["Caddy is running", ""];
527
779
  if (serverNames.length === 0) {
528
780
  lines.push("No HTTP servers configured");
529
781
  } else {
530
782
  for (const name of serverNames) {
531
- lines.push(`Server "${name}": ${describeServer(servers[name])}`);
783
+ lines.push(`Server "${name}": ${describeServer(servers[name], httpPort, httpsPort)}`);
532
784
  }
533
785
  }
534
786
  const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
@@ -562,7 +814,7 @@ ${lines.join("\n")}` }]
562
814
  );
563
815
  server.tool(
564
816
  "caddy_upstreams",
565
- "Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
817
+ "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.",
566
818
  {},
567
819
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
568
820
  async () => formatResult(await getUpstreams())
@@ -636,7 +888,9 @@ function registerResources(server) {
636
888
  server.resource(
637
889
  "caddy-upstreams",
638
890
  "caddy://upstreams",
639
- { description: "Reverse proxy upstream health status" },
891
+ {
892
+ 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."
893
+ },
640
894
  async () => {
641
895
  const res = await getUpstreams();
642
896
  return {
@@ -702,7 +956,7 @@ function registerResources(server) {
702
956
 
703
957
  // src/tools/adapt.ts
704
958
  import { z as z2 } from "zod";
705
- function formatWarning(w) {
959
+ function formatWarning2(w) {
706
960
  if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
707
961
  const obj = w;
708
962
  const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
@@ -712,7 +966,7 @@ function formatWarning(w) {
712
966
  function registerAdaptTools(server) {
713
967
  server.tool(
714
968
  "caddy_adapt",
715
- "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.",
969
+ `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.`,
716
970
  {
717
971
  config: z2.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
718
972
  adapter: z2.string().regex(/^[a-z0-9_-]+$/, "Adapter must be lowercase alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
@@ -728,7 +982,7 @@ function registerAdaptTools(server) {
728
982
  const result = data.result;
729
983
  const content = [];
730
984
  if (warnings.length > 0) {
731
- const warnLines = warnings.map(formatWarning);
985
+ const warnLines = warnings.map(formatWarning2);
732
986
  content.push({ type: "text", text: `Warnings:
733
987
  ${warnLines.join("\n")}` });
734
988
  }
@@ -822,6 +1076,9 @@ function getSnapshot(index) {
822
1076
  }
823
1077
 
824
1078
  // src/tools/config.ts
1079
+ function isRootConfigPath(path) {
1080
+ return /^\/*$/.test(path.replace(/^\/?(config(\/|$))?/, ""));
1081
+ }
825
1082
  function registerConfigTools(server) {
826
1083
  server.tool(
827
1084
  "caddy_config_get",
@@ -832,12 +1089,12 @@ function registerConfigTools(server) {
832
1089
  );
833
1090
  server.tool(
834
1091
  "caddy_config_set",
835
- "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.",
1092
+ "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.",
836
1093
  {
837
1094
  path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
838
1095
  value: z3.any().describe("The JSON value to set at the path"),
839
1096
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
840
- "'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
1097
+ "'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)"
841
1098
  )
842
1099
  },
843
1100
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -848,7 +1105,7 @@ function registerConfigTools(server) {
848
1105
  );
849
1106
  server.tool(
850
1107
  "caddy_config_delete",
851
- "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.",
1108
+ "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.",
852
1109
  {
853
1110
  path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
854
1111
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
@@ -859,27 +1116,54 @@ function registerConfigTools(server) {
859
1116
  // array after a delete, so repeating that call removes a DIFFERENT route each
860
1117
  // time. caddy_remove_route carries the same correction for the byte-identical
861
1118
  // underlying request; the two must agree. Nothing here is auto-recoverable
862
- // either: only caddy_load captures a snapshot, so a spurious repeat cannot be
863
- // undone with caddy_revert.
1119
+ // either: apart from a root delete (below), only caddy_load captures a
1120
+ // snapshot, so a spurious repeat cannot be undone with caddy_revert.
864
1121
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
865
1122
  async ({ path, confirm }) => {
1123
+ const root = isRootConfigPath(path);
866
1124
  if (!confirm) {
867
1125
  return {
868
1126
  isError: true,
869
1127
  content: [
870
1128
  {
871
1129
  type: "text",
872
- text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
1130
+ 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.`
873
1131
  }
874
1132
  ]
875
1133
  };
876
1134
  }
877
- return formatResult(await configDelete(path));
1135
+ if (!root) return formatResult(await configDelete(path));
1136
+ const current = await configGet();
1137
+ const res = await configDelete("");
1138
+ const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1139
+ if (kept) saveSnapshot(current.data, "caddy_config_delete");
1140
+ if (!res.ok) {
1141
+ if (res.outcomeUnknown && kept) {
1142
+ return formatResult({
1143
+ ...res,
1144
+ error: `${res.error}
1145
+ 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.`
1146
+ });
1147
+ }
1148
+ return formatResult(res);
1149
+ }
1150
+ 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.";
1151
+ const adm = kept ? current.data.admin : void 0;
1152
+ const listen = adm !== null && typeof adm === "object" && !Array.isArray(adm) ? adm.listen : void 0;
1153
+ 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.` : "";
1154
+ return {
1155
+ content: [
1156
+ {
1157
+ type: "text",
1158
+ text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
1159
+ }
1160
+ ]
1161
+ };
878
1162
  }
879
1163
  );
880
1164
  server.tool(
881
1165
  "caddy_load",
882
- "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.",
1166
+ "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.",
883
1167
  {
884
1168
  config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
885
1169
  format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
@@ -901,15 +1185,21 @@ function registerConfigTools(server) {
901
1185
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
902
1186
  const current = await configGet();
903
1187
  const res = await loadConfig(config, contentType);
904
- if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
905
- saveSnapshot(current.data, "caddy_load");
1188
+ const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1189
+ if (kept) saveSnapshot(current.data, "caddy_load");
1190
+ if (res.outcomeUnknown && kept) {
1191
+ return formatResult({
1192
+ ...res,
1193
+ error: `${res.error}
1194
+ 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.`
1195
+ });
906
1196
  }
907
1197
  return formatResult(res);
908
1198
  }
909
1199
  );
910
1200
  server.tool(
911
1201
  "caddy_revert",
912
- "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).",
1202
+ "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).",
913
1203
  {
914
1204
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
915
1205
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -972,7 +1262,19 @@ ${lines.join("\n")}` }] };
972
1262
  }
973
1263
  const current = await configGet();
974
1264
  const res = await loadConfig(snap.config, "application/json");
975
- if (!res.ok) return formatResult(res);
1265
+ if (!res.ok) {
1266
+ if (res.outcomeUnknown && current.ok && isSnapshotableConfig(current.data)) {
1267
+ saveSnapshot(current.data, "caddy_revert");
1268
+ const now = listSnapshots().indexOf(snap);
1269
+ 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}]`;
1270
+ return formatResult({
1271
+ ...res,
1272
+ error: `${res.error}
1273
+ 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.`
1274
+ });
1275
+ }
1276
+ return formatResult(res);
1277
+ }
976
1278
  const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
977
1279
  if (capturedRollforward) {
978
1280
  saveSnapshot(current.data, "caddy_revert");
@@ -996,7 +1298,7 @@ ${lines.join("\n")}` }] };
996
1298
  value: z3.any().optional().describe("New value (required for 'set' action)"),
997
1299
  subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
998
1300
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
999
- "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
1301
+ "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."
1000
1302
  ),
1001
1303
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
1002
1304
  },
@@ -1152,7 +1454,7 @@ function serverNotFoundError(srv, op = "operation") {
1152
1454
  content: [
1153
1455
  {
1154
1456
  type: "text",
1155
- 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.`
1457
+ 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.`
1156
1458
  }
1157
1459
  ]
1158
1460
  };
@@ -1163,7 +1465,7 @@ function serverNullError(srv) {
1163
1465
  content: [
1164
1466
  {
1165
1467
  type: "text",
1166
- 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"] }`
1468
+ 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.`
1167
1469
  }
1168
1470
  ]
1169
1471
  };
@@ -1546,7 +1848,7 @@ function buildTlsConfig(fields) {
1546
1848
  }
1547
1849
  };
1548
1850
  }
1549
- function bothErrors(label, patchRes, writeRes, writeLabel) {
1851
+ function bothErrors(label, patchRes, writeRes, writeLabel, hint) {
1550
1852
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
1551
1853
  const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
1552
1854
  return {
@@ -1556,11 +1858,16 @@ function bothErrors(label, patchRes, writeRes, writeLabel) {
1556
1858
  type: "text",
1557
1859
  text: `Error: Failed to set ${label}.
1558
1860
  PATCH attempt: ${patchErr}
1559
- ${writeLabel} fallback: ${writeErr}`
1861
+ ${writeLabel} fallback: ${writeErr}` + (hint ? `
1862
+ ${hint}` : "")
1560
1863
  }
1561
1864
  ]
1562
1865
  };
1563
1866
  }
1867
+ function isAbsentOnGet(res) {
1868
+ if (res.ok) return res.data === void 0 || res.data === null;
1869
+ return isMissingConfigPath(res);
1870
+ }
1564
1871
  function isPlainObject(v) {
1565
1872
  return typeof v === "object" && v !== null && !Array.isArray(v);
1566
1873
  }
@@ -1623,13 +1930,15 @@ function refuseFallback(label, patchRes, detail) {
1623
1930
  }
1624
1931
  };
1625
1932
  }
1933
+ 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.";
1626
1934
  async function safeFallback(label, patchRes, fields) {
1627
1935
  const getRes = await configGet("apps/tls");
1628
- const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1936
+ const absent = isAbsentOnGet(getRes);
1629
1937
  if (absent) {
1630
- const postRes = await configPost("apps/tls", buildTlsConfig(fields));
1631
- if (postRes.ok) return { kind: "ok" };
1632
- return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
1938
+ const putRes = await configPut("apps/tls", buildTlsConfig(fields));
1939
+ if (putRes.ok) return { kind: "ok" };
1940
+ const hint = putRes.status === 409 ? CREATE_CONFLICT_HINT : void 0;
1941
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT", hint) };
1633
1942
  }
1634
1943
  if (!getRes.ok) {
1635
1944
  return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
@@ -1671,29 +1980,39 @@ function missingArgError(text) {
1671
1980
  function registerTlsTools(server) {
1672
1981
  server.tool(
1673
1982
  "caddy_tls",
1674
- "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.",
1983
+ "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.",
1675
1984
  {
1676
1985
  action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1677
1986
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
1678
1987
  ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
1679
1988
  profile: z5.string().optional().describe(
1680
- "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."
1989
+ "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."
1681
1990
  )
1682
1991
  },
1683
1992
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1684
1993
  async ({ action, email, ca, profile }) => {
1685
1994
  if (action === "status") {
1686
- return formatResult(await configGet("apps/tls"));
1995
+ const tlsRes = await configGet("apps/tls");
1996
+ if (isAbsentOnGet(tlsRes)) {
1997
+ return {
1998
+ content: [
1999
+ {
2000
+ type: "text",
2001
+ 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."
2002
+ }
2003
+ ]
2004
+ };
2005
+ }
2006
+ return formatResult(tlsRes);
1687
2007
  }
1688
2008
  if (action === "ech_status") {
1689
- const echRes = await configGet("apps/tls/ech");
1690
- const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
1691
- if (absent) {
2009
+ const echRes = await configGet("apps/tls/encrypted_client_hello");
2010
+ if (isAbsentOnGet(echRes)) {
1692
2011
  return {
1693
2012
  content: [
1694
2013
  {
1695
2014
  type: "text",
1696
- 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."
2015
+ 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."
1697
2016
  }
1698
2017
  ]
1699
2018
  };