@yawlabs/caddy-mcp 2.5.2 → 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 +9 -9
- package/dist/api.d.ts +20 -0
- package/dist/format.d.ts +10 -1
- package/dist/index.js +225 -71
- package/dist/server.js +225 -71
- package/package.json +1 -1
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
|
|
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
|
|
85
|
-
| `CADDY_TIMEOUT` | `10000` | Timeout in ms for
|
|
86
|
-
| `CADDY_LOAD_TIMEOUT` | `
|
|
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
|
|
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.
|
|
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;
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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"
|
|
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,
|
|
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
|
|
140
|
-
let
|
|
141
|
-
while (
|
|
142
|
-
|
|
143
|
-
const backoff = Math.min(RETRY_BASE_MS * 2 ** (
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
291
|
+
function parseJsonOrUndefined(text) {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(text);
|
|
294
|
+
} catch {
|
|
295
|
+
return void 0;
|
|
296
|
+
}
|
|
249
297
|
}
|
|
250
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
470
|
+
if (raw === void 0) return LOAD_TIMEOUT;
|
|
378
471
|
const n = Number(raw);
|
|
379
|
-
if (!Number.isFinite(n)) return
|
|
472
|
+
if (!Number.isFinite(n)) return LOAD_TIMEOUT;
|
|
380
473
|
const floored = Math.floor(n);
|
|
381
|
-
if (floored < 1) return
|
|
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,
|
|
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}`,
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
905
|
-
|
|
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)
|
|
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
|
|
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: "
|
|
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
|
|
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 =
|
|
1771
|
+
const absent = isAbsentOnGet(getRes);
|
|
1629
1772
|
if (absent) {
|
|
1630
|
-
const
|
|
1631
|
-
if (
|
|
1632
|
-
|
|
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
|
-
|
|
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/
|
|
1690
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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,
|
|
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
|
|
138
|
-
let
|
|
139
|
-
while (
|
|
140
|
-
|
|
141
|
-
const backoff = Math.min(RETRY_BASE_MS * 2 ** (
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
289
|
+
function parseJsonOrUndefined(text) {
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(text);
|
|
292
|
+
} catch {
|
|
293
|
+
return void 0;
|
|
294
|
+
}
|
|
247
295
|
}
|
|
248
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
468
|
+
if (raw === void 0) return LOAD_TIMEOUT;
|
|
376
469
|
const n = Number(raw);
|
|
377
|
-
if (!Number.isFinite(n)) return
|
|
470
|
+
if (!Number.isFinite(n)) return LOAD_TIMEOUT;
|
|
378
471
|
const floored = Math.floor(n);
|
|
379
|
-
if (floored < 1) return
|
|
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,
|
|
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}`,
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
903
|
-
|
|
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)
|
|
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
|
|
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: "
|
|
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
|
|
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 =
|
|
1769
|
+
const absent = isAbsentOnGet(getRes);
|
|
1627
1770
|
if (absent) {
|
|
1628
|
-
const
|
|
1629
|
-
if (
|
|
1630
|
-
|
|
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
|
-
|
|
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/
|
|
1688
|
-
|
|
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
|
|
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.
|
|
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",
|