@yawlabs/caddy-mcp 2.5.1 → 2.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ Other Caddy MCP servers wrap half the admin API and silently swallow errors. Thi
21
21
  - **Safe-by-default mutations** — `caddy_config_set` defaults to idempotent `overwrite` (PATCH), not `append` (POST). Calling twice doesn't duplicate your route.
22
22
  - **Defensive parsing** — `caddy_list_routes` never crashes on malformed config, even if routes are null, handlers are strings, or matchers are non-arrays. Regression-tested.
23
23
  - **No leaked credentials in errors** — if `CADDY_ADMIN_URL` contains a token in the path/query, the connect-failed message shows only the origin.
24
- - **Fallback error surfacing** — when a TLS write PATCH fails and the POST fallback also fails, both error bodies are returned so you know what actually went wrong.
24
+ - **Fallback error surfacing** — when a TLS write PATCH fails and the PUT fallback also fails, both error bodies are returned so you know what actually went wrong.
25
25
  - **Tool annotations** — every tool declares `readOnlyHint`, `destructiveHint`, and `idempotentHint`, so MCP clients can skip confirmations for safe ops.
26
26
  - **Instant startup** — ships as a single bundle with two runtime deps (the MCP SDK + Zod). No 5-minute `node_modules` install.
27
27
  - **Input hardening** — adapter names, `@id` values, server names, and CA ids are all regex-validated with length caps. Blocks CRLF header injection and ReDoS.
@@ -81,9 +81,9 @@ That's it. Now ask your AI assistant:
81
81
  | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. Also accepts a unix socket, in either `unix:///var/run/caddy-admin.sock` or Caddy's own `unix//var/run/caddy-admin.sock` spelling — see below. |
82
82
  | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
83
83
  | `CADDY_MCP_SNAPSHOT_DIR` | (none) | Directory for persisting `caddy_revert` snapshots. Unset, snapshots live in memory only and are lost when this server restarts. Snapshots are full Caddy configs and can contain secrets, so the location is opt-in rather than defaulted. |
84
- | `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures (5xx, network errors). 4xx and 412 never retry. POSTs to `/config/*` and `/id/*` also skip retry (non-idempotent appends/creates -- retrying could duplicate routes or 409 a half-applied create). POSTs to `/load`, `/adapt`, `/stop` still retry. Hard-capped at 5; values above the cap log a one-time stderr notice so the clamp is visible. Set to `0` to disable. |
85
- | `CADDY_TIMEOUT` | `10000` | Timeout in ms for all admin API requests except `/load` (which uses `CADDY_LOAD_TIMEOUT`). Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
86
- | `CADDY_LOAD_TIMEOUT` | `60000` | Timeout in ms for the `/load` endpoint; raise for ACME-heavy bring-ups where provisioning many certificates can exceed the default. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
84
+ | `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures: network errors, and 502/503/504 (which only a proxy in front of Caddy sends). Caddy's own 500s are deterministic rejections and never retry, nor do 4xx and 412. Requests a replay could change the outcome of also skip retry: POSTs to `/config/*` and `/id/*` (they append, or replace an existing key -- retrying could duplicate routes), and a PUT or DELETE at an array index such as `.../routes/0` (a replay would insert a second route, or remove the one that slid into that index), including a PUT to a bare `/id/<id>`, which Caddy resolves to the identified element's array index. A config change whose timeout fired is never retried (see `CADDY_LOAD_TIMEOUT`). A refused connection retries for every method, since nothing was sent. POSTs to `/load`, `/adapt`, `/stop` still retry. Hard-capped at 5; values above the cap log a one-time stderr notice so the clamp is visible. Set to `0` to disable. |
85
+ | `CADDY_TIMEOUT` | `10000` | Timeout in ms for admin API requests that do not change the config: GETs, `/adapt`, `/stop`, PKI, upstreams and metrics. Config changes use `CADDY_LOAD_TIMEOUT`. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
86
+ | `CADDY_LOAD_TIMEOUT` | `55000` | Timeout in ms for every request that changes the config: `POST /load`, and every POST/PUT/PATCH/DELETE under `/config/*` and `/id/*`. Each is a full synchronous reload inside Caddy, which can legitimately run long -- Caddy sleeps through `apps.http.shutdown_delay` inside the reload whenever a change closes a listener, and a change can wait behind another reload. A timeout here is never retried: Caddy keeps applying a change after the client gives up, so the error says the outcome is unknown and to re-read the config before retrying (`caddy_load` and `caddy_revert` keep their snapshot in that case). Keep it below your MCP client's request timeout (60 s by default in the MCP SDK), or that error arrives after the client has given up and is never shown; the default sits 5 s under it. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
87
87
 
88
88
  **Unix socket admin endpoints:**
89
89
 
@@ -118,11 +118,11 @@ Use the same JSON block shown above in any of these.
118
118
  ### Config management (6)
119
119
 
120
120
  - **caddy_config_get** — Read config at any JSON path (or the full config).
121
- - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
121
+ - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent; the key must exist), `append` (POST: appends to an array, but replaces an existing non-array key and cannot create missing parents), `insert` (PUT: inserts at an array position, or strictly creates a key along with any missing parents and fails with 409 if it exists — the way to create a server or app, even on an instance with no config).
122
122
  - **caddy_config_delete** — Delete config at a path. Requires `confirm=true` (deleting a parent path also removes every descendant).
123
123
  - **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths. The `delete` action requires `confirm=true`.
124
- - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning. Auto-snapshots the prior config.
125
- - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10.
124
+ - **caddy_load** — Replace the entire config atomically. Runs on `CADDY_LOAD_TIMEOUT` (55 seconds by default), like every config change. Auto-snapshots the prior config, and keeps that snapshot when the load times out with its outcome unknown. Lists the Caddyfile adapter's warnings, and reports a load that failed as an error even when Caddy answered HTTP 200 — which Caddy 2.11.4 does for a Caddyfile that adapted with warnings ([caddyserver/caddy#7246](https://github.com/caddyserver/caddy/issues/7246)).
125
+ - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10. An `apply` that times out with its outcome unknown keeps the pre-revert config as snapshot [0], which shifts every older snapshot down one index; the error says where the target now sits.
126
126
 
127
127
  ### Route operations (4)
128
128
 
@@ -133,7 +133,7 @@ Use the same JSON block shown above in any of these.
133
133
 
134
134
  ### TLS & config conversion (2)
135
135
 
136
- - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. PATCH first; on a fresh install, POSTs a minimal config. On an existing config it deep-merges into the issuer path and PUTs the result back, preserving siblings (custom certs, `on_demand`, additional policies). Refuses with a shape-specific error if the existing structure is unexpected — never clobbers.
136
+ - **caddy_tls** — Check or set TLS settings. Actions: `status`, `set_email` (ACME email), `set_acme_ca` (ACME CA URL), `set_acme_profile` (ACME profile, Caddy 2.10+), and the read-only `ech_status` (the Encrypted ClientHello config at `apps/tls/encrypted_client_hello`, Caddy 2.10+). The set actions PATCH first; when `apps/tls` is not set they PUT a minimal config, which also creates any missing parents, so they work on an instance with no config at all. On an existing config they deep-merge into the issuer path and PATCH the result back, preserving siblings (custom certs, `on_demand`, additional policies). Refuses with a shape-specific error if the existing structure is unexpected — never clobbers.
137
137
  - **caddy_adapt** — Convert a config in any registered adapter format to Caddy JSON without applying it. `caddyfile` (built-in, default) plus any adapter module compiled into your Caddy binary — e.g., `nginx` ([caddy-nginx-adapter](https://github.com/caddyserver/nginx-adapter)), `yaml` ([caddy-yaml](https://github.com/abiosoft/caddy-yaml)). Great for previewing or porting from existing configs.
138
138
 
139
139
  ### Server operations (6)
@@ -265,7 +265,7 @@ npm install
265
265
  npm run lint # Biome check
266
266
  npm run lint:fix # Auto-fix
267
267
  npm run build # tsup bundle
268
- npm test # Vitest (357 unit tests, +9 POSIX-only unix-socket tests; +13 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
268
+ npm test # Vitest (636 unit tests, +39 POSIX-only unix-socket and launcher tests; +32 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
269
269
  npm run typecheck # tsc --noEmit
270
270
  ```
271
271
 
package/dist/api.d.ts CHANGED
@@ -4,6 +4,26 @@ export interface ApiResponse<T = any> {
4
4
  data?: T;
5
5
  error?: string;
6
6
  etag?: string;
7
+ /**
8
+ * Config-adapter warnings Caddy attached to a `POST /load` answer, set on a
9
+ * load that applied and on one that failed alike (see readLoadBody). Kept
10
+ * apart from `data` and `error` so neither has to be a rendered string;
11
+ * formatResult prints them for every consumer, so none can drop them.
12
+ * Elements are whatever Caddy sent -- `{file, line, directive, message}` on
13
+ * 2.11.4 -- and are not validated here.
14
+ */
15
+ warnings?: unknown[];
16
+ /**
17
+ * Set (to true) only on a failure where this client's deadline fired on a
18
+ * config change (isConfigChange): no answer came, and Caddy may have applied
19
+ * the change, may still apply it, or may never have received it. `ok` is
20
+ * false, because nothing confirmed success -- but unlike every other failure
21
+ * it does NOT mean "nothing changed", and a caller that skips its bookkeeping
22
+ * on `!ok` needs to know the difference. caddy_load and caddy_revert apply key
23
+ * on it to keep the snapshot they would otherwise drop. A flag rather than a
24
+ * match on the error text, so rewording the message cannot change behaviour.
25
+ */
26
+ outcomeUnknown?: boolean;
7
27
  }
8
28
  /**
9
29
  * Caddy's "a segment of this config path does not exist" failure.
package/dist/format.d.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import type { ApiResponse } from "./api.js";
2
- /** Convert an API response to MCP tool result format */
2
+ /**
3
+ * Convert an API response to MCP tool result format.
4
+ *
5
+ * `warnings` are appended to the SAME text item as the result, on a success and
6
+ * on an error alike, rather than sent as a second content item. They are set
7
+ * only by `POST /load` today, where they matter most on the failure path -- a
8
+ * load Caddy refused, reported next to the adapter warnings that came with it --
9
+ * and a client that reads only `content[0]` must still see both. Rendering them
10
+ * here, not in caddy_load, means no consumer of an ApiResponse can drop them.
11
+ */
3
12
  export declare function formatResult(res: ApiResponse): {
4
13
  isError: boolean;
5
14
  content: {
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) {
@@ -108,25 +109,36 @@ function sleep(ms) {
108
109
  function isTransientFailure(res) {
109
110
  if (res.ok) return false;
110
111
  if (res.status === 0) return true;
111
- if (res.status >= 500 && res.status <= 599) return true;
112
- return false;
112
+ return res.status === 502 || res.status === 503 || res.status === 504;
113
113
  }
114
114
  function isMissingConfigPath(res) {
115
115
  if (res.ok) return false;
116
116
  return (res.error ?? "").toLowerCase().includes("invalid traversal path");
117
117
  }
118
- var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
118
+ var ARRAY_INDEX_TAIL_RE = /\/\d+\/*$/;
119
+ var BARE_ID_RE = /^\/id\/[^/]+\/*$/;
120
+ function isConfigChange(method, path) {
121
+ if (method === "GET") return false;
122
+ return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
123
+ }
119
124
  function isRetryableMethod(method, path) {
120
- if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
125
+ if (method === "PUT" && BARE_ID_RE.test(path)) return false;
126
+ if (method === "PUT" || method === "DELETE") return !ARRAY_INDEX_TAIL_RE.test(path);
121
127
  if (method !== "POST") return true;
122
128
  return !path.startsWith("/config/") && !path.startsWith("/id/");
123
129
  }
130
+ function shouldRetry(method, path, attempt) {
131
+ if (!isTransientFailure(attempt.res)) return false;
132
+ if (attempt.refused) return true;
133
+ if (attempt.timedOut && isConfigChange(method, path)) return false;
134
+ return isRetryableMethod(method, path);
135
+ }
124
136
  function getMalformedUnixUrl() {
125
137
  const raw = (process.env.CADDY_ADMIN_URL || "").trim();
126
138
  if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
127
139
  return getUnixSocketPath() === void 0 ? raw : void 0;
128
140
  }
129
- async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
141
+ async function caddyRequest(method, path, body, contentType, rawStringBody = false) {
130
142
  const malformed = getMalformedUnixUrl();
131
143
  if (malformed) {
132
144
  return {
@@ -136,16 +148,16 @@ async function caddyRequest(method, path, body, contentType, timeout, rawStringB
136
148
  };
137
149
  }
138
150
  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);
151
+ let retries = 0;
152
+ let attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
153
+ while (retries < maxRetries && shouldRetry(method, path, attempt)) {
154
+ retries++;
155
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
144
156
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
145
157
  await sleep(delay);
146
- ({ res, refused } = await attemptRequest(method, path, body, contentType, timeout, rawStringBody));
158
+ attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
147
159
  }
148
- return res;
160
+ return attempt.res;
149
161
  }
150
162
  function isConnectionRefused(err) {
151
163
  let current = err;
@@ -159,27 +171,44 @@ function isConnectionRefused(err) {
159
171
  }
160
172
  return false;
161
173
  }
174
+ function isTimeoutError(err) {
175
+ let current = err;
176
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
177
+ const e = current;
178
+ if (e.name === "TimeoutError" || e.name === "AbortError") return true;
179
+ current = e.cause;
180
+ }
181
+ return false;
182
+ }
162
183
  function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
163
184
  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
- });
185
+ let deadlineHit = false;
186
+ const deadline = Object.assign(new Error(`timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
187
+ function fail(err) {
188
+ clearTimeout(timer);
189
+ reject(deadlineHit ? deadline : err);
190
+ }
191
+ const req = httpRequest({ socketPath, path, method, headers, agent: false }, (res) => {
192
+ const chunks = [];
193
+ res.on("data", (chunk) => chunks.push(chunk));
194
+ res.on("error", fail);
195
+ res.on("end", () => {
196
+ clearTimeout(timer);
197
+ const status = res.statusCode ?? 0;
198
+ const etag = res.headers.etag;
199
+ resolve({
200
+ ok: status >= 200 && status < 300,
201
+ status,
202
+ text: Buffer.concat(chunks).toString("utf8"),
203
+ etag: typeof etag === "string" ? etag : void 0
179
204
  });
180
- }
181
- );
182
- req.on("error", reject);
205
+ });
206
+ });
207
+ const timer = setTimeout(() => {
208
+ deadlineHit = true;
209
+ req.destroy(deadline);
210
+ }, timeoutMs);
211
+ req.on("error", fail);
183
212
  if (body !== void 0) req.write(body);
184
213
  req.end();
185
214
  });
@@ -238,19 +267,65 @@ function settleAdminRestart(origin) {
238
267
  socketWaiters.add(check);
239
268
  });
240
269
  }
241
- function isConfigChange(method, path) {
242
- if (method === "GET") return false;
243
- return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
270
+ function endOfFirstJsonValue(text) {
271
+ let depth = 0;
272
+ let inString = false;
273
+ let escaped = false;
274
+ for (let i = 0; i < text.length; i++) {
275
+ const ch = text[i];
276
+ if (inString) {
277
+ if (escaped) escaped = false;
278
+ else if (ch === "\\") escaped = true;
279
+ else if (ch === '"') inString = false;
280
+ continue;
281
+ }
282
+ if (ch === '"') inString = true;
283
+ else if (ch === "[" || ch === "{") depth++;
284
+ else if (ch === "]" || ch === "}") {
285
+ depth--;
286
+ if (depth <= 0) return depth === 0 ? i + 1 : -1;
287
+ }
288
+ }
289
+ return -1;
244
290
  }
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 };
291
+ function parseJsonOrUndefined(text) {
292
+ try {
293
+ return JSON.parse(text);
294
+ } catch {
295
+ return void 0;
296
+ }
249
297
  }
250
- async function sendOnce(transport, method, path, body, contentType, timeout, rawStringBody = false) {
298
+ function readLoadBody(text) {
299
+ const body = text.trim();
300
+ if (body.startsWith("[")) {
301
+ const end = endOfFirstJsonValue(body);
302
+ if (end === -1) return void 0;
303
+ const warnings = parseJsonOrUndefined(body.slice(0, end));
304
+ if (!Array.isArray(warnings)) return void 0;
305
+ const tail = body.slice(end).trim();
306
+ if (!tail) return { warnings };
307
+ const trailing = parseJsonOrUndefined(tail);
308
+ if (trailing === null || typeof trailing !== "object" || Array.isArray(trailing)) return void 0;
309
+ if (typeof trailing.error !== "string") return void 0;
310
+ return { warnings, errorText: tail };
311
+ }
312
+ if (body.startsWith("{")) {
313
+ const parsed = parseJsonOrUndefined(body);
314
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
315
+ const warnings = parsed.warnings;
316
+ if (Array.isArray(warnings) && Object.keys(parsed).length === 1) return { warnings };
317
+ }
318
+ return void 0;
319
+ }
320
+ async function attemptRequest(method, path, body, contentType, rawStringBody = false) {
321
+ const transport = { refused: false, timedOut: false };
322
+ const res = await sendOnce(transport, method, path, body, contentType, rawStringBody);
323
+ return { res, refused: transport.refused, timedOut: transport.timedOut };
324
+ }
325
+ async function sendOnce(transport, method, path, body, contentType, rawStringBody = false) {
251
326
  const socketPath = getUnixSocketPath();
252
327
  const url = `${getBaseUrl()}${path}`;
253
- const effectiveTimeout = timeout ?? getRequestTimeout();
328
+ const effectiveTimeout = getTimeoutFor(method, path);
254
329
  try {
255
330
  const hasBody = body !== void 0;
256
331
  const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
@@ -262,6 +337,15 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
262
337
  }
263
338
  const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
264
339
  const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
340
+ const loadBody = path === "/load" && res.ok ? readLoadBody(res.text) : void 0;
341
+ if (loadBody?.errorText !== void 0) {
342
+ return {
343
+ ok: false,
344
+ status: res.status,
345
+ 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.`,
346
+ warnings: loadBody.warnings
347
+ };
348
+ }
265
349
  if (!socketPath && res.ok && isConfigChange(method, path)) await settleAdminRestart(getAdminOrigin());
266
350
  const text = res.text;
267
351
  const etag = res.etag;
@@ -298,6 +382,7 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
298
382
  return { ok: false, status: res.status, error: text };
299
383
  }
300
384
  if (!text) return { ok: true, status: res.status, etag };
385
+ if (loadBody) return { ok: true, status: res.status, warnings: loadBody.warnings, etag };
301
386
  try {
302
387
  return { ok: true, status: res.status, data: JSON.parse(text), etag };
303
388
  } catch {
@@ -327,8 +412,16 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
327
412
  error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
328
413
  };
329
414
  }
330
- if (msg.includes("abort") || msg.includes("timeout")) {
331
- return { ok: false, status: 0, error: `Request timed out after ${effectiveTimeout}ms` };
415
+ if (isTimeoutError(err) || msg.includes("abort") || msg.includes("timeout") || msg.includes("timed out")) {
416
+ transport.timedOut = true;
417
+ const timedOutMsg = `Request timed out after ${effectiveTimeout}ms`;
418
+ if (!isConfigChange(method, path)) return { ok: false, status: 0, error: timedOutMsg };
419
+ return {
420
+ ok: false,
421
+ status: 0,
422
+ outcomeUnknown: true,
423
+ 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.`
424
+ };
332
425
  }
333
426
  return { ok: false, status: 0, error: msg };
334
427
  }
@@ -374,20 +467,23 @@ function getRequestTimeout() {
374
467
  }
375
468
  function getLoadTimeout() {
376
469
  const raw = process.env.CADDY_LOAD_TIMEOUT;
377
- if (raw === void 0) return 6e4;
470
+ if (raw === void 0) return LOAD_TIMEOUT;
378
471
  const n = Number(raw);
379
- if (!Number.isFinite(n)) return 6e4;
472
+ if (!Number.isFinite(n)) return LOAD_TIMEOUT;
380
473
  const floored = Math.floor(n);
381
- if (floored < 1) return 6e4;
474
+ if (floored < 1) return LOAD_TIMEOUT;
382
475
  return floored;
383
476
  }
477
+ function getTimeoutFor(method, path) {
478
+ return isConfigChange(method, path) ? getLoadTimeout() : getRequestTimeout();
479
+ }
384
480
  async function loadConfig(config, contentType) {
385
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
481
+ const res = await caddyRequest("POST", "/load", config, contentType, true);
386
482
  if (res.ok) etagCache.clear();
387
483
  return res;
388
484
  }
389
485
  function adapt(config, adapter = "caddyfile") {
390
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
486
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, true);
391
487
  }
392
488
  function stop() {
393
489
  return caddyRequest("POST", "/stop");
@@ -441,16 +537,39 @@ function getMetrics() {
441
537
  import { z } from "zod";
442
538
 
443
539
  // src/format.ts
540
+ var WARNING_KEYS = /* @__PURE__ */ new Set(["file", "line", "directive", "message"]);
541
+ function formatWarning(w) {
542
+ const asJson = () => ` - ${JSON.stringify(w)}`;
543
+ if (w === null || typeof w !== "object" || Array.isArray(w)) return asJson();
544
+ const obj = w;
545
+ if (Object.keys(obj).some((key) => !WARNING_KEYS.has(key))) return asJson();
546
+ const { file, line, directive, message } = obj;
547
+ if (typeof message !== "string" || message === "") return asJson();
548
+ if (file !== void 0 && typeof file !== "string") return asJson();
549
+ if (line !== void 0 && typeof line !== "number") return asJson();
550
+ if (directive !== void 0 && typeof directive !== "string") return asJson();
551
+ const where = file && line !== void 0 ? `${file}:${line}` : file || (line !== void 0 ? `line ${line}` : "");
552
+ const prefix = [where, directive ? `(${directive})` : ""].filter(Boolean).join(" ");
553
+ return ` - ${prefix ? `${prefix}: ` : ""}${message}`;
554
+ }
555
+ function formatWarnings(warnings) {
556
+ if (!warnings || warnings.length === 0) return "";
557
+ return `
558
+
559
+ Adapter warnings (${warnings.length}):
560
+ ${warnings.map(formatWarning).join("\n")}`;
561
+ }
444
562
  function formatResult(res) {
563
+ const warnings = formatWarnings(res.warnings);
445
564
  if (!res.ok) {
446
565
  return {
447
566
  isError: true,
448
- content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
567
+ content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}${warnings}` }]
449
568
  };
450
569
  }
451
570
  const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
452
571
  const text = raw || "OK";
453
- return { content: [{ type: "text", text }] };
572
+ return { content: [{ type: "text", text: `${text}${warnings}` }] };
454
573
  }
455
574
 
456
575
  // src/tools/operational.ts
@@ -702,7 +821,7 @@ function registerResources(server) {
702
821
 
703
822
  // src/tools/adapt.ts
704
823
  import { z as z2 } from "zod";
705
- function formatWarning(w) {
824
+ function formatWarning2(w) {
706
825
  if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
707
826
  const obj = w;
708
827
  const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
@@ -728,7 +847,7 @@ function registerAdaptTools(server) {
728
847
  const result = data.result;
729
848
  const content = [];
730
849
  if (warnings.length > 0) {
731
- const warnLines = warnings.map(formatWarning);
850
+ const warnLines = warnings.map(formatWarning2);
732
851
  content.push({ type: "text", text: `Warnings:
733
852
  ${warnLines.join("\n")}` });
734
853
  }
@@ -832,12 +951,12 @@ function registerConfigTools(server) {
832
951
  );
833
952
  server.tool(
834
953
  "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.",
954
+ "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
955
  {
837
956
  path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
838
957
  value: z3.any().describe("The JSON value to set at the path"),
839
958
  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)"
959
+ "'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
960
  )
842
961
  },
843
962
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -879,7 +998,7 @@ function registerConfigTools(server) {
879
998
  );
880
999
  server.tool(
881
1000
  "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.",
1001
+ "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
1002
  {
884
1003
  config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
885
1004
  format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
@@ -901,8 +1020,14 @@ function registerConfigTools(server) {
901
1020
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
902
1021
  const current = await configGet();
903
1022
  const res = await loadConfig(config, contentType);
904
- if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
905
- saveSnapshot(current.data, "caddy_load");
1023
+ const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1024
+ if (kept) saveSnapshot(current.data, "caddy_load");
1025
+ if (res.outcomeUnknown && kept) {
1026
+ return formatResult({
1027
+ ...res,
1028
+ error: `${res.error}
1029
+ 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.`
1030
+ });
906
1031
  }
907
1032
  return formatResult(res);
908
1033
  }
@@ -972,7 +1097,19 @@ ${lines.join("\n")}` }] };
972
1097
  }
973
1098
  const current = await configGet();
974
1099
  const res = await loadConfig(snap.config, "application/json");
975
- if (!res.ok) return formatResult(res);
1100
+ if (!res.ok) {
1101
+ if (res.outcomeUnknown && current.ok && isSnapshotableConfig(current.data)) {
1102
+ saveSnapshot(current.data, "caddy_revert");
1103
+ const now = listSnapshots().indexOf(snap);
1104
+ 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}]`;
1105
+ return formatResult({
1106
+ ...res,
1107
+ error: `${res.error}
1108
+ 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.`
1109
+ });
1110
+ }
1111
+ return formatResult(res);
1112
+ }
976
1113
  const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
977
1114
  if (capturedRollforward) {
978
1115
  saveSnapshot(current.data, "caddy_revert");
@@ -996,7 +1133,7 @@ ${lines.join("\n")}` }] };
996
1133
  value: z3.any().optional().describe("New value (required for 'set' action)"),
997
1134
  subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
998
1135
  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)"
1136
+ "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
1137
  ),
1001
1138
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
1002
1139
  },
@@ -1152,7 +1289,7 @@ function serverNotFoundError(srv, op = "operation") {
1152
1289
  content: [
1153
1290
  {
1154
1291
  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.`
1292
+ 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
1293
  }
1157
1294
  ]
1158
1295
  };
@@ -1163,7 +1300,7 @@ function serverNullError(srv) {
1163
1300
  content: [
1164
1301
  {
1165
1302
  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"] }`
1303
+ 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
1304
  }
1168
1305
  ]
1169
1306
  };
@@ -1546,7 +1683,7 @@ function buildTlsConfig(fields) {
1546
1683
  }
1547
1684
  };
1548
1685
  }
1549
- function bothErrors(label, patchRes, writeRes, writeLabel) {
1686
+ function bothErrors(label, patchRes, writeRes, writeLabel, hint) {
1550
1687
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
1551
1688
  const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
1552
1689
  return {
@@ -1556,11 +1693,16 @@ function bothErrors(label, patchRes, writeRes, writeLabel) {
1556
1693
  type: "text",
1557
1694
  text: `Error: Failed to set ${label}.
1558
1695
  PATCH attempt: ${patchErr}
1559
- ${writeLabel} fallback: ${writeErr}`
1696
+ ${writeLabel} fallback: ${writeErr}` + (hint ? `
1697
+ ${hint}` : "")
1560
1698
  }
1561
1699
  ]
1562
1700
  };
1563
1701
  }
1702
+ function isAbsentOnGet(res) {
1703
+ if (res.ok) return res.data === void 0 || res.data === null;
1704
+ return isMissingConfigPath(res);
1705
+ }
1564
1706
  function isPlainObject(v) {
1565
1707
  return typeof v === "object" && v !== null && !Array.isArray(v);
1566
1708
  }
@@ -1623,13 +1765,15 @@ function refuseFallback(label, patchRes, detail) {
1623
1765
  }
1624
1766
  };
1625
1767
  }
1768
+ 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
1769
  async function safeFallback(label, patchRes, fields) {
1627
1770
  const getRes = await configGet("apps/tls");
1628
- const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1771
+ const absent = isAbsentOnGet(getRes);
1629
1772
  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") };
1773
+ const putRes = await configPut("apps/tls", buildTlsConfig(fields));
1774
+ if (putRes.ok) return { kind: "ok" };
1775
+ const hint = putRes.status === 409 ? CREATE_CONFLICT_HINT : void 0;
1776
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT", hint) };
1633
1777
  }
1634
1778
  if (!getRes.ok) {
1635
1779
  return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
@@ -1671,7 +1815,7 @@ function missingArgError(text) {
1671
1815
  function registerTlsTools(server) {
1672
1816
  server.tool(
1673
1817
  "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.",
1818
+ "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
1819
  {
1676
1820
  action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1677
1821
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
@@ -1683,17 +1827,27 @@ function registerTlsTools(server) {
1683
1827
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1684
1828
  async ({ action, email, ca, profile }) => {
1685
1829
  if (action === "status") {
1686
- return formatResult(await configGet("apps/tls"));
1830
+ const tlsRes = await configGet("apps/tls");
1831
+ if (isAbsentOnGet(tlsRes)) {
1832
+ return {
1833
+ content: [
1834
+ {
1835
+ type: "text",
1836
+ 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."
1837
+ }
1838
+ ]
1839
+ };
1840
+ }
1841
+ return formatResult(tlsRes);
1687
1842
  }
1688
1843
  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) {
1844
+ const echRes = await configGet("apps/tls/encrypted_client_hello");
1845
+ if (isAbsentOnGet(echRes)) {
1692
1846
  return {
1693
1847
  content: [
1694
1848
  {
1695
1849
  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."
1850
+ 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
1851
  }
1698
1852
  ]
1699
1853
  };
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) {
@@ -106,25 +107,36 @@ function sleep(ms) {
106
107
  function isTransientFailure(res) {
107
108
  if (res.ok) return false;
108
109
  if (res.status === 0) return true;
109
- if (res.status >= 500 && res.status <= 599) return true;
110
- return false;
110
+ return res.status === 502 || res.status === 503 || res.status === 504;
111
111
  }
112
112
  function isMissingConfigPath(res) {
113
113
  if (res.ok) return false;
114
114
  return (res.error ?? "").toLowerCase().includes("invalid traversal path");
115
115
  }
116
- var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
116
+ var ARRAY_INDEX_TAIL_RE = /\/\d+\/*$/;
117
+ var BARE_ID_RE = /^\/id\/[^/]+\/*$/;
118
+ function isConfigChange(method, path) {
119
+ if (method === "GET") return false;
120
+ return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
121
+ }
117
122
  function isRetryableMethod(method, path) {
118
- if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
123
+ if (method === "PUT" && BARE_ID_RE.test(path)) return false;
124
+ if (method === "PUT" || method === "DELETE") return !ARRAY_INDEX_TAIL_RE.test(path);
119
125
  if (method !== "POST") return true;
120
126
  return !path.startsWith("/config/") && !path.startsWith("/id/");
121
127
  }
128
+ function shouldRetry(method, path, attempt) {
129
+ if (!isTransientFailure(attempt.res)) return false;
130
+ if (attempt.refused) return true;
131
+ if (attempt.timedOut && isConfigChange(method, path)) return false;
132
+ return isRetryableMethod(method, path);
133
+ }
122
134
  function getMalformedUnixUrl() {
123
135
  const raw = (process.env.CADDY_ADMIN_URL || "").trim();
124
136
  if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
125
137
  return getUnixSocketPath() === void 0 ? raw : void 0;
126
138
  }
127
- async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
139
+ async function caddyRequest(method, path, body, contentType, rawStringBody = false) {
128
140
  const malformed = getMalformedUnixUrl();
129
141
  if (malformed) {
130
142
  return {
@@ -134,16 +146,16 @@ async function caddyRequest(method, path, body, contentType, timeout, rawStringB
134
146
  };
135
147
  }
136
148
  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);
149
+ let retries = 0;
150
+ let attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
151
+ while (retries < maxRetries && shouldRetry(method, path, attempt)) {
152
+ retries++;
153
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
142
154
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
143
155
  await sleep(delay);
144
- ({ res, refused } = await attemptRequest(method, path, body, contentType, timeout, rawStringBody));
156
+ attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
145
157
  }
146
- return res;
158
+ return attempt.res;
147
159
  }
148
160
  function isConnectionRefused(err) {
149
161
  let current = err;
@@ -157,27 +169,44 @@ function isConnectionRefused(err) {
157
169
  }
158
170
  return false;
159
171
  }
172
+ function isTimeoutError(err) {
173
+ let current = err;
174
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
175
+ const e = current;
176
+ if (e.name === "TimeoutError" || e.name === "AbortError") return true;
177
+ current = e.cause;
178
+ }
179
+ return false;
180
+ }
160
181
  function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
161
182
  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
- });
183
+ let deadlineHit = false;
184
+ const deadline = Object.assign(new Error(`timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
185
+ function fail(err) {
186
+ clearTimeout(timer);
187
+ reject(deadlineHit ? deadline : err);
188
+ }
189
+ const req = httpRequest({ socketPath, path, method, headers, agent: false }, (res) => {
190
+ const chunks = [];
191
+ res.on("data", (chunk) => chunks.push(chunk));
192
+ res.on("error", fail);
193
+ res.on("end", () => {
194
+ clearTimeout(timer);
195
+ const status = res.statusCode ?? 0;
196
+ const etag = res.headers.etag;
197
+ resolve({
198
+ ok: status >= 200 && status < 300,
199
+ status,
200
+ text: Buffer.concat(chunks).toString("utf8"),
201
+ etag: typeof etag === "string" ? etag : void 0
177
202
  });
178
- }
179
- );
180
- req.on("error", reject);
203
+ });
204
+ });
205
+ const timer = setTimeout(() => {
206
+ deadlineHit = true;
207
+ req.destroy(deadline);
208
+ }, timeoutMs);
209
+ req.on("error", fail);
181
210
  if (body !== void 0) req.write(body);
182
211
  req.end();
183
212
  });
@@ -236,19 +265,65 @@ function settleAdminRestart(origin) {
236
265
  socketWaiters.add(check);
237
266
  });
238
267
  }
239
- function isConfigChange(method, path) {
240
- if (method === "GET") return false;
241
- return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
268
+ function endOfFirstJsonValue(text) {
269
+ let depth = 0;
270
+ let inString = false;
271
+ let escaped = false;
272
+ for (let i = 0; i < text.length; i++) {
273
+ const ch = text[i];
274
+ if (inString) {
275
+ if (escaped) escaped = false;
276
+ else if (ch === "\\") escaped = true;
277
+ else if (ch === '"') inString = false;
278
+ continue;
279
+ }
280
+ if (ch === '"') inString = true;
281
+ else if (ch === "[" || ch === "{") depth++;
282
+ else if (ch === "]" || ch === "}") {
283
+ depth--;
284
+ if (depth <= 0) return depth === 0 ? i + 1 : -1;
285
+ }
286
+ }
287
+ return -1;
242
288
  }
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 };
289
+ function parseJsonOrUndefined(text) {
290
+ try {
291
+ return JSON.parse(text);
292
+ } catch {
293
+ return void 0;
294
+ }
247
295
  }
248
- async function sendOnce(transport, method, path, body, contentType, timeout, rawStringBody = false) {
296
+ function readLoadBody(text) {
297
+ const body = text.trim();
298
+ if (body.startsWith("[")) {
299
+ const end = endOfFirstJsonValue(body);
300
+ if (end === -1) return void 0;
301
+ const warnings = parseJsonOrUndefined(body.slice(0, end));
302
+ if (!Array.isArray(warnings)) return void 0;
303
+ const tail = body.slice(end).trim();
304
+ if (!tail) return { warnings };
305
+ const trailing = parseJsonOrUndefined(tail);
306
+ if (trailing === null || typeof trailing !== "object" || Array.isArray(trailing)) return void 0;
307
+ if (typeof trailing.error !== "string") return void 0;
308
+ return { warnings, errorText: tail };
309
+ }
310
+ if (body.startsWith("{")) {
311
+ const parsed = parseJsonOrUndefined(body);
312
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
313
+ const warnings = parsed.warnings;
314
+ if (Array.isArray(warnings) && Object.keys(parsed).length === 1) return { warnings };
315
+ }
316
+ return void 0;
317
+ }
318
+ async function attemptRequest(method, path, body, contentType, rawStringBody = false) {
319
+ const transport = { refused: false, timedOut: false };
320
+ const res = await sendOnce(transport, method, path, body, contentType, rawStringBody);
321
+ return { res, refused: transport.refused, timedOut: transport.timedOut };
322
+ }
323
+ async function sendOnce(transport, method, path, body, contentType, rawStringBody = false) {
249
324
  const socketPath = getUnixSocketPath();
250
325
  const url = `${getBaseUrl()}${path}`;
251
- const effectiveTimeout = timeout ?? getRequestTimeout();
326
+ const effectiveTimeout = getTimeoutFor(method, path);
252
327
  try {
253
328
  const hasBody = body !== void 0;
254
329
  const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
@@ -260,6 +335,15 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
260
335
  }
261
336
  const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
262
337
  const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
338
+ const loadBody = path === "/load" && res.ok ? readLoadBody(res.text) : void 0;
339
+ if (loadBody?.errorText !== void 0) {
340
+ return {
341
+ ok: false,
342
+ status: res.status,
343
+ 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.`,
344
+ warnings: loadBody.warnings
345
+ };
346
+ }
263
347
  if (!socketPath && res.ok && isConfigChange(method, path)) await settleAdminRestart(getAdminOrigin());
264
348
  const text = res.text;
265
349
  const etag = res.etag;
@@ -296,6 +380,7 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
296
380
  return { ok: false, status: res.status, error: text };
297
381
  }
298
382
  if (!text) return { ok: true, status: res.status, etag };
383
+ if (loadBody) return { ok: true, status: res.status, warnings: loadBody.warnings, etag };
299
384
  try {
300
385
  return { ok: true, status: res.status, data: JSON.parse(text), etag };
301
386
  } catch {
@@ -325,8 +410,16 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
325
410
  error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
326
411
  };
327
412
  }
328
- if (msg.includes("abort") || msg.includes("timeout")) {
329
- return { ok: false, status: 0, error: `Request timed out after ${effectiveTimeout}ms` };
413
+ if (isTimeoutError(err) || msg.includes("abort") || msg.includes("timeout") || msg.includes("timed out")) {
414
+ transport.timedOut = true;
415
+ const timedOutMsg = `Request timed out after ${effectiveTimeout}ms`;
416
+ if (!isConfigChange(method, path)) return { ok: false, status: 0, error: timedOutMsg };
417
+ return {
418
+ ok: false,
419
+ status: 0,
420
+ outcomeUnknown: true,
421
+ 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.`
422
+ };
330
423
  }
331
424
  return { ok: false, status: 0, error: msg };
332
425
  }
@@ -372,20 +465,23 @@ function getRequestTimeout() {
372
465
  }
373
466
  function getLoadTimeout() {
374
467
  const raw = process.env.CADDY_LOAD_TIMEOUT;
375
- if (raw === void 0) return 6e4;
468
+ if (raw === void 0) return LOAD_TIMEOUT;
376
469
  const n = Number(raw);
377
- if (!Number.isFinite(n)) return 6e4;
470
+ if (!Number.isFinite(n)) return LOAD_TIMEOUT;
378
471
  const floored = Math.floor(n);
379
- if (floored < 1) return 6e4;
472
+ if (floored < 1) return LOAD_TIMEOUT;
380
473
  return floored;
381
474
  }
475
+ function getTimeoutFor(method, path) {
476
+ return isConfigChange(method, path) ? getLoadTimeout() : getRequestTimeout();
477
+ }
382
478
  async function loadConfig(config, contentType) {
383
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
479
+ const res = await caddyRequest("POST", "/load", config, contentType, true);
384
480
  if (res.ok) etagCache.clear();
385
481
  return res;
386
482
  }
387
483
  function adapt(config, adapter = "caddyfile") {
388
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
484
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, true);
389
485
  }
390
486
  function stop() {
391
487
  return caddyRequest("POST", "/stop");
@@ -439,16 +535,39 @@ function getMetrics() {
439
535
  import { z } from "zod";
440
536
 
441
537
  // src/format.ts
538
+ var WARNING_KEYS = /* @__PURE__ */ new Set(["file", "line", "directive", "message"]);
539
+ function formatWarning(w) {
540
+ const asJson = () => ` - ${JSON.stringify(w)}`;
541
+ if (w === null || typeof w !== "object" || Array.isArray(w)) return asJson();
542
+ const obj = w;
543
+ if (Object.keys(obj).some((key) => !WARNING_KEYS.has(key))) return asJson();
544
+ const { file, line, directive, message } = obj;
545
+ if (typeof message !== "string" || message === "") return asJson();
546
+ if (file !== void 0 && typeof file !== "string") return asJson();
547
+ if (line !== void 0 && typeof line !== "number") return asJson();
548
+ if (directive !== void 0 && typeof directive !== "string") return asJson();
549
+ const where = file && line !== void 0 ? `${file}:${line}` : file || (line !== void 0 ? `line ${line}` : "");
550
+ const prefix = [where, directive ? `(${directive})` : ""].filter(Boolean).join(" ");
551
+ return ` - ${prefix ? `${prefix}: ` : ""}${message}`;
552
+ }
553
+ function formatWarnings(warnings) {
554
+ if (!warnings || warnings.length === 0) return "";
555
+ return `
556
+
557
+ Adapter warnings (${warnings.length}):
558
+ ${warnings.map(formatWarning).join("\n")}`;
559
+ }
442
560
  function formatResult(res) {
561
+ const warnings = formatWarnings(res.warnings);
443
562
  if (!res.ok) {
444
563
  return {
445
564
  isError: true,
446
- content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
565
+ content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}${warnings}` }]
447
566
  };
448
567
  }
449
568
  const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
450
569
  const text = raw || "OK";
451
- return { content: [{ type: "text", text }] };
570
+ return { content: [{ type: "text", text: `${text}${warnings}` }] };
452
571
  }
453
572
 
454
573
  // src/tools/operational.ts
@@ -700,7 +819,7 @@ function registerResources(server) {
700
819
 
701
820
  // src/tools/adapt.ts
702
821
  import { z as z2 } from "zod";
703
- function formatWarning(w) {
822
+ function formatWarning2(w) {
704
823
  if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
705
824
  const obj = w;
706
825
  const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
@@ -726,7 +845,7 @@ function registerAdaptTools(server) {
726
845
  const result = data.result;
727
846
  const content = [];
728
847
  if (warnings.length > 0) {
729
- const warnLines = warnings.map(formatWarning);
848
+ const warnLines = warnings.map(formatWarning2);
730
849
  content.push({ type: "text", text: `Warnings:
731
850
  ${warnLines.join("\n")}` });
732
851
  }
@@ -830,12 +949,12 @@ function registerConfigTools(server) {
830
949
  );
831
950
  server.tool(
832
951
  "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.",
952
+ "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
953
  {
835
954
  path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
836
955
  value: z3.any().describe("The JSON value to set at the path"),
837
956
  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)"
957
+ "'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
958
  )
840
959
  },
841
960
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -877,7 +996,7 @@ function registerConfigTools(server) {
877
996
  );
878
997
  server.tool(
879
998
  "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.",
999
+ "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
1000
  {
882
1001
  config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
883
1002
  format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
@@ -899,8 +1018,14 @@ function registerConfigTools(server) {
899
1018
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
900
1019
  const current = await configGet();
901
1020
  const res = await loadConfig(config, contentType);
902
- if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
903
- saveSnapshot(current.data, "caddy_load");
1021
+ const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1022
+ if (kept) saveSnapshot(current.data, "caddy_load");
1023
+ if (res.outcomeUnknown && kept) {
1024
+ return formatResult({
1025
+ ...res,
1026
+ error: `${res.error}
1027
+ 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.`
1028
+ });
904
1029
  }
905
1030
  return formatResult(res);
906
1031
  }
@@ -970,7 +1095,19 @@ ${lines.join("\n")}` }] };
970
1095
  }
971
1096
  const current = await configGet();
972
1097
  const res = await loadConfig(snap.config, "application/json");
973
- if (!res.ok) return formatResult(res);
1098
+ if (!res.ok) {
1099
+ if (res.outcomeUnknown && current.ok && isSnapshotableConfig(current.data)) {
1100
+ saveSnapshot(current.data, "caddy_revert");
1101
+ const now = listSnapshots().indexOf(snap);
1102
+ 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}]`;
1103
+ return formatResult({
1104
+ ...res,
1105
+ error: `${res.error}
1106
+ 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.`
1107
+ });
1108
+ }
1109
+ return formatResult(res);
1110
+ }
974
1111
  const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
975
1112
  if (capturedRollforward) {
976
1113
  saveSnapshot(current.data, "caddy_revert");
@@ -994,7 +1131,7 @@ ${lines.join("\n")}` }] };
994
1131
  value: z3.any().optional().describe("New value (required for 'set' action)"),
995
1132
  subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
996
1133
  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)"
1134
+ "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
1135
  ),
999
1136
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
1000
1137
  },
@@ -1150,7 +1287,7 @@ function serverNotFoundError(srv, op = "operation") {
1150
1287
  content: [
1151
1288
  {
1152
1289
  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.`
1290
+ 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
1291
  }
1155
1292
  ]
1156
1293
  };
@@ -1161,7 +1298,7 @@ function serverNullError(srv) {
1161
1298
  content: [
1162
1299
  {
1163
1300
  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"] }`
1301
+ 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
1302
  }
1166
1303
  ]
1167
1304
  };
@@ -1544,7 +1681,7 @@ function buildTlsConfig(fields) {
1544
1681
  }
1545
1682
  };
1546
1683
  }
1547
- function bothErrors(label, patchRes, writeRes, writeLabel) {
1684
+ function bothErrors(label, patchRes, writeRes, writeLabel, hint) {
1548
1685
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
1549
1686
  const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
1550
1687
  return {
@@ -1554,11 +1691,16 @@ function bothErrors(label, patchRes, writeRes, writeLabel) {
1554
1691
  type: "text",
1555
1692
  text: `Error: Failed to set ${label}.
1556
1693
  PATCH attempt: ${patchErr}
1557
- ${writeLabel} fallback: ${writeErr}`
1694
+ ${writeLabel} fallback: ${writeErr}` + (hint ? `
1695
+ ${hint}` : "")
1558
1696
  }
1559
1697
  ]
1560
1698
  };
1561
1699
  }
1700
+ function isAbsentOnGet(res) {
1701
+ if (res.ok) return res.data === void 0 || res.data === null;
1702
+ return isMissingConfigPath(res);
1703
+ }
1562
1704
  function isPlainObject(v) {
1563
1705
  return typeof v === "object" && v !== null && !Array.isArray(v);
1564
1706
  }
@@ -1621,13 +1763,15 @@ function refuseFallback(label, patchRes, detail) {
1621
1763
  }
1622
1764
  };
1623
1765
  }
1766
+ 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
1767
  async function safeFallback(label, patchRes, fields) {
1625
1768
  const getRes = await configGet("apps/tls");
1626
- const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1769
+ const absent = isAbsentOnGet(getRes);
1627
1770
  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") };
1771
+ const putRes = await configPut("apps/tls", buildTlsConfig(fields));
1772
+ if (putRes.ok) return { kind: "ok" };
1773
+ const hint = putRes.status === 409 ? CREATE_CONFLICT_HINT : void 0;
1774
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT", hint) };
1631
1775
  }
1632
1776
  if (!getRes.ok) {
1633
1777
  return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
@@ -1669,7 +1813,7 @@ function missingArgError(text) {
1669
1813
  function registerTlsTools(server) {
1670
1814
  server.tool(
1671
1815
  "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.",
1816
+ "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
1817
  {
1674
1818
  action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1675
1819
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
@@ -1681,17 +1825,27 @@ function registerTlsTools(server) {
1681
1825
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1682
1826
  async ({ action, email, ca, profile }) => {
1683
1827
  if (action === "status") {
1684
- return formatResult(await configGet("apps/tls"));
1828
+ const tlsRes = await configGet("apps/tls");
1829
+ if (isAbsentOnGet(tlsRes)) {
1830
+ return {
1831
+ content: [
1832
+ {
1833
+ type: "text",
1834
+ 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."
1835
+ }
1836
+ ]
1837
+ };
1838
+ }
1839
+ return formatResult(tlsRes);
1685
1840
  }
1686
1841
  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) {
1842
+ const echRes = await configGet("apps/tls/encrypted_client_hello");
1843
+ if (isAbsentOnGet(echRes)) {
1690
1844
  return {
1691
1845
  content: [
1692
1846
  {
1693
1847
  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."
1848
+ 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
1849
  }
1696
1850
  ]
1697
1851
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "Caddy MCP server for Claude Code, Cursor, and any MCP client: admin API, config, routes, reverse proxy, TLS, PKI, metrics",
6
6
  "license": "MIT",
@@ -38,16 +38,16 @@
38
38
  "start": "node dist/index.js"
39
39
  },
40
40
  "dependencies": {
41
- "@modelcontextprotocol/sdk": "^1.29.0",
41
+ "@modelcontextprotocol/sdk": "^1.30.0",
42
42
  "zod": "^4.3.6"
43
43
  },
44
44
  "overrides": {
45
- "hono": "^4.12.21",
46
- "@hono/node-server": "^1.19.13",
47
- "postcss": "^8.5.10",
48
- "ip-address": "^10.1.1",
49
- "fast-uri": "^3.1.2",
50
- "qs": "^6.15.2",
45
+ "hono": "^4.13.5",
46
+ "@hono/node-server": "^1.19.15",
47
+ "postcss": "^8.5.23",
48
+ "ip-address": "^10.3.1",
49
+ "fast-uri": "^3.1.6",
50
+ "qs": "^6.16.0",
51
51
  "esbuild": "^0.28.1"
52
52
  },
53
53
  "devDependencies": {