@yawlabs/caddy-mcp 2.2.0 → 2.3.0

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
@@ -78,12 +78,29 @@ That's it. Now ask your AI assistant:
78
78
 
79
79
  | Environment variable | Default | Description |
80
80
  |---|---|---|
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. |
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
+ | `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. |
83
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. |
84
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. |
85
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. |
86
87
 
88
+ **Unix socket admin endpoints:**
89
+
90
+ Caddy's recommended hardening is to move the admin API off a loopback port and
91
+ onto a unix socket, where access is governed by filesystem permissions:
92
+
93
+ ```
94
+ {
95
+ admin unix//var/run/caddy-admin.sock
96
+ }
97
+ ```
98
+
99
+ Point `CADDY_ADMIN_URL` at the same path (`unix:///var/run/caddy-admin.sock`)
100
+ and requests are sent over the socket instead of TCP. The process running
101
+ caddy-mcp needs read/write permission on the socket file. `CADDY_API_TOKEN`
102
+ still applies if you have auth in front of the endpoint.
103
+
87
104
  **Alternate MCP clients:**
88
105
 
89
106
  | Client | Config file |
@@ -217,6 +234,17 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
217
234
  - You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
218
235
  - Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
219
236
 
237
+ **`SIGUSR1` / `systemctl reload caddy` stops reloading the Caddyfile**
238
+
239
+ - Expected, and not caused by a bug here. Since Caddy 2.11.1, `SIGUSR1` reloads
240
+ from the file on disk **only if the config has never been changed through the
241
+ admin API**. The first write from caddy-mcp (or any other API client) makes
242
+ Caddy consider the running config API-owned, and `SIGUSR1` becomes a no-op.
243
+ - Pick one owner per instance. If the Caddyfile is the source of truth, use
244
+ caddy-mcp read-only tools (`caddy_status`, `caddy_list_routes`, `caddy_adapt`)
245
+ and reload from the file. If caddy-mcp owns the config, apply changes with
246
+ `caddy_load` instead of `SIGUSR1`.
247
+
220
248
  **Windows: MCP server doesn't start**
221
249
 
222
250
  - Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
@@ -224,7 +252,9 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
224
252
  ## Requirements
225
253
 
226
254
  - Node.js 20+
227
- - Caddy 2.x with admin API enabled (default: `localhost:2019`)
255
+ - Caddy 2.x with admin API enabled (default: `localhost:2019`). Verified against
256
+ Caddy 2.11.4; the `@id` write path relies on `PATCH` semantics that the live
257
+ integration suite pins per release.
228
258
 
229
259
  ## Contributing
230
260
 
@@ -235,7 +265,7 @@ npm install
235
265
  npm run lint # Biome check
236
266
  npm run lint:fix # Auto-fix
237
267
  npm run build # tsup bundle
238
- npm test # Vitest (230 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
268
+ npm test # Vitest (357 unit tests, +9 POSIX-only unix-socket tests; +13 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
239
269
  npm run typecheck # tsc --noEmit
240
270
  ```
241
271
 
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
 
8
8
  // src/api.ts
9
+ import { request as httpRequest } from "http";
9
10
  var DEFAULT_URL = "http://localhost:2019";
10
11
  var TIMEOUT = 1e4;
11
12
  var RETRY_BASE_MS = 100;
@@ -45,6 +46,15 @@ function invalidateRelated(path) {
45
46
  function getBaseUrl() {
46
47
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
47
48
  }
49
+ function getUnixSocketPath() {
50
+ const raw = (process.env.CADDY_ADMIN_URL || "").trim();
51
+ if (!raw) return void 0;
52
+ const urlForm = /^unix:\/\/(\/.*)$/.exec(raw);
53
+ if (urlForm) return urlForm[1];
54
+ const caddyForm = /^unix\/(\/.*)$/.exec(raw);
55
+ if (caddyForm) return caddyForm[1];
56
+ return void 0;
57
+ }
48
58
  var warnedRetryClamp = false;
49
59
  function getMaxRetries() {
50
60
  const raw = process.env.CADDY_MAX_RETRIES;
@@ -65,11 +75,12 @@ function getAdminOrigin() {
65
75
  return void 0;
66
76
  }
67
77
  }
68
- function getHeaders(contentType) {
78
+ function getHeaders(contentType, overUnixSocket = false) {
69
79
  const headers = {};
70
80
  if (contentType) headers["Content-Type"] = contentType;
71
81
  const token = process.env.CADDY_API_TOKEN;
72
82
  if (token) headers.Authorization = `Bearer ${token}`;
83
+ if (overUnixSocket) return headers;
73
84
  const origin = getAdminOrigin();
74
85
  if (origin) headers.Origin = origin;
75
86
  return headers;
@@ -102,39 +113,85 @@ function isRetryableMethod(method, path) {
102
113
  if (method !== "POST") return true;
103
114
  return !path.startsWith("/config/") && !path.startsWith("/id/");
104
115
  }
105
- async function caddyRequest(method, path, body, contentType, timeout) {
116
+ function getMalformedUnixUrl() {
117
+ const raw = (process.env.CADDY_ADMIN_URL || "").trim();
118
+ if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
119
+ return getUnixSocketPath() === void 0 ? raw : void 0;
120
+ }
121
+ async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
122
+ const malformed = getMalformedUnixUrl();
123
+ if (malformed) {
124
+ return {
125
+ ok: false,
126
+ status: 0,
127
+ error: `CADDY_ADMIN_URL="${malformed}" looks like a unix socket address but is not a recognized form. Use "unix:///absolute/path.sock" (URL form) or "unix//absolute/path.sock" (Caddy's own spelling). A single slash after "unix:", or a relative path, will not parse.`
128
+ };
129
+ }
106
130
  const maxRetries = getMaxRetries();
107
131
  let attempt = 0;
108
- let res = await attemptRequest(method, path, body, contentType, timeout);
132
+ let res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
109
133
  while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
110
134
  attempt++;
111
135
  const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
112
136
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
113
137
  await sleep(delay);
114
- res = await attemptRequest(method, path, body, contentType, timeout);
138
+ res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
115
139
  }
116
140
  return res;
117
141
  }
118
- async function attemptRequest(method, path, body, contentType, timeout) {
142
+ function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
143
+ return new Promise((resolve, reject) => {
144
+ const req = httpRequest({ socketPath, path, method, headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
145
+ const chunks = [];
146
+ res.on("data", (chunk) => chunks.push(chunk));
147
+ res.on("error", reject);
148
+ res.on("end", () => {
149
+ const status = res.statusCode ?? 0;
150
+ const etag = res.headers.etag;
151
+ resolve({
152
+ ok: status >= 200 && status < 300,
153
+ status,
154
+ text: Buffer.concat(chunks).toString("utf8"),
155
+ etag: typeof etag === "string" ? etag : void 0
156
+ });
157
+ });
158
+ });
159
+ req.on("error", reject);
160
+ if (body !== void 0) req.write(body);
161
+ req.end();
162
+ });
163
+ }
164
+ async function sendViaFetch(url, method, headers, body, timeoutMs) {
165
+ const res = await fetch(url, {
166
+ method,
167
+ headers,
168
+ body,
169
+ signal: AbortSignal.timeout(timeoutMs)
170
+ });
171
+ return {
172
+ ok: res.ok,
173
+ status: res.status,
174
+ text: await res.text(),
175
+ etag: res.headers.get("ETag") || void 0
176
+ };
177
+ }
178
+ async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
179
+ const socketPath = getUnixSocketPath();
119
180
  const url = `${getBaseUrl()}${path}`;
120
181
  const effectiveTimeout = timeout ?? getRequestTimeout();
121
182
  try {
122
183
  const hasBody = body !== void 0;
123
- const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
184
+ const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
124
185
  const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
125
186
  const isWrite = method !== "GET";
126
187
  if (isWrite && isConfigPath) {
127
188
  const cachedEtag = etagCache.get(path);
128
189
  if (cachedEtag) headers["If-Match"] = cachedEtag;
129
190
  }
130
- const res = await fetch(url, {
131
- method,
132
- headers,
133
- body: hasBody ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
134
- signal: AbortSignal.timeout(effectiveTimeout)
135
- });
136
- const text = await res.text();
137
- const etag = res.headers.get("ETag") || void 0;
191
+ const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
192
+ const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
193
+ const text = res.text;
194
+ const etag = res.etag;
138
195
  if (method === "GET" && etag && isConfigPath) {
139
196
  setEtag(path, etag);
140
197
  }
@@ -158,10 +215,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
158
215
  return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
159
216
  }
160
217
  if (res.status === 403 && /origin/i.test(text)) {
218
+ const hint = socketPath ? `Over a unix socket caddy-mcp deliberately sends no Origin header, because Caddy builds no default origin allowlist for a unix listener (sending one would fail against an empty list). Reaching here means admin.enforce_origin is enabled -- disable it, or move the admin endpoint to a TCP address.` : `Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`;
161
219
  return {
162
220
  ok: false,
163
221
  status: 403,
164
- error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`
222
+ error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. ${hint}`
165
223
  };
166
224
  }
167
225
  return { ok: false, status: res.status, error: text };
@@ -174,17 +232,25 @@ async function attemptRequest(method, path, body, contentType, timeout) {
174
232
  }
175
233
  } catch (err) {
176
234
  const msg = err instanceof Error ? err.message : String(err);
235
+ if (socketPath && msg.includes("ENOENT")) {
236
+ return {
237
+ ok: false,
238
+ status: 0,
239
+ error: `No socket at ${socketPath} \u2014 check the path in CADDY_ADMIN_URL and that Caddy's admin endpoint is configured to listen on it.`
240
+ };
241
+ }
177
242
  if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
178
- const baseUrl = getBaseUrl();
179
- let origin = baseUrl;
180
- try {
181
- origin = new URL(baseUrl).origin;
182
- } catch {
243
+ let target = socketPath ?? getBaseUrl();
244
+ if (!socketPath) {
245
+ try {
246
+ target = new URL(target).origin;
247
+ } catch {
248
+ }
183
249
  }
184
250
  return {
185
251
  ok: false,
186
252
  status: 0,
187
- error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
253
+ error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
188
254
  };
189
255
  }
190
256
  if (msg.includes("abort") || msg.includes("timeout")) {
@@ -242,12 +308,12 @@ function getLoadTimeout() {
242
308
  return floored;
243
309
  }
244
310
  async function loadConfig(config, contentType) {
245
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
311
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
246
312
  if (res.ok) etagCache.clear();
247
313
  return res;
248
314
  }
249
315
  function adapt(config, adapter = "caddyfile") {
250
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
316
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
251
317
  }
252
318
  function stop() {
253
319
  return caddyRequest("POST", "/stop");
@@ -594,25 +660,83 @@ ${warnLines.join("\n")}` });
594
660
  import { z as z3 } from "zod";
595
661
 
596
662
  // src/snapshots.ts
663
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
664
+ import { join } from "path";
597
665
  var MAX_SNAPSHOTS = 10;
598
666
  var store = [];
667
+ function isSnapshotableConfig(data) {
668
+ return data !== null && typeof data === "object" && !Array.isArray(data);
669
+ }
670
+ var SNAPSHOT_FILE_RE = /^snapshot-(\d+)-([\w-]+)\.json$/;
671
+ function snapshotDir() {
672
+ const dir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
673
+ return dir ? dir : void 0;
674
+ }
675
+ var hydrated = false;
676
+ function hydrate() {
677
+ if (hydrated) return;
678
+ hydrated = true;
679
+ const dir = snapshotDir();
680
+ if (!dir || !existsSync(dir)) return;
681
+ try {
682
+ const names = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse().slice(0, MAX_SNAPSHOTS);
683
+ const loaded = [];
684
+ for (const name of names) {
685
+ const m = SNAPSHOT_FILE_RE.exec(name);
686
+ if (!m) continue;
687
+ try {
688
+ const config = JSON.parse(readFileSync(join(dir, name), "utf-8"));
689
+ if (!isSnapshotableConfig(config)) continue;
690
+ loaded.push({ config, timestamp: Number(m[1]), trigger: m[2] });
691
+ } catch {
692
+ }
693
+ }
694
+ loaded.sort((a, b) => b.timestamp - a.timestamp);
695
+ store.push(...loaded);
696
+ } catch {
697
+ }
698
+ }
699
+ function persist(snap) {
700
+ const dir = snapshotDir();
701
+ if (!dir) return;
702
+ try {
703
+ mkdirSync(dir, { recursive: true });
704
+ const safeTrigger = snap.trigger.replace(/[^\w-]/g, "_");
705
+ writeFileSync(
706
+ join(dir, `snapshot-${snap.timestamp}-${safeTrigger}.json`),
707
+ JSON.stringify(snap.config, null, 2),
708
+ "utf-8"
709
+ );
710
+ const files = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse();
711
+ for (const stale of files.slice(MAX_SNAPSHOTS)) {
712
+ rmSync(join(dir, stale), { force: true });
713
+ }
714
+ } catch {
715
+ }
716
+ }
599
717
  function saveSnapshot(config, trigger) {
600
- store.unshift({ config, timestamp: Date.now(), trigger });
718
+ hydrate();
719
+ let timestamp = Date.now();
720
+ if (store.length > 0 && timestamp <= store[0].timestamp) {
721
+ timestamp = store[0].timestamp + 1;
722
+ }
723
+ const snap = { config, timestamp, trigger };
724
+ store.unshift(snap);
601
725
  if (store.length > MAX_SNAPSHOTS) {
602
726
  store.length = MAX_SNAPSHOTS;
603
727
  }
728
+ persist(snap);
604
729
  }
605
730
  function listSnapshots() {
731
+ hydrate();
606
732
  return store;
607
733
  }
608
734
  function getSnapshot(index) {
735
+ hydrate();
609
736
  return store[index];
610
737
  }
611
738
 
612
739
  // src/tools/config.ts
613
- function isSnapshotableConfig(data) {
614
- return data !== null && typeof data === "object" && !Array.isArray(data);
615
- }
616
740
  function registerConfigTools(server) {
617
741
  server.tool(
618
742
  "caddy_config_get",
@@ -692,7 +816,7 @@ function registerConfigTools(server) {
692
816
  );
693
817
  server.tool(
694
818
  "caddy_revert",
695
- "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load and kept in-memory (last 10). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
819
+ "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (last 10). By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
696
820
  {
697
821
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
698
822
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -1235,6 +1359,7 @@ function buildTlsConfig(fields) {
1235
1359
  const issuer = { module: "acme" };
1236
1360
  if (fields.email) issuer.email = fields.email;
1237
1361
  if (fields.ca) issuer.ca = fields.ca;
1362
+ if (fields.profile) issuer.profile = fields.profile;
1238
1363
  return {
1239
1364
  automation: {
1240
1365
  policies: [{ issuers: [issuer] }]
@@ -1268,6 +1393,7 @@ function mergeIssuerFields(existing, fields) {
1268
1393
  const issuer = automation.policies[0].issuers[0];
1269
1394
  if (fields.email !== void 0) issuer.email = fields.email;
1270
1395
  if (fields.ca !== void 0) issuer.ca = fields.ca;
1396
+ if (fields.profile !== void 0) issuer.profile = fields.profile;
1271
1397
  return merged;
1272
1398
  }
1273
1399
  function validateIssuerShape(tls) {
@@ -1338,47 +1464,63 @@ async function safeFallback(label, patchRes, fields) {
1338
1464
  };
1339
1465
  }
1340
1466
  const merged = mergeIssuerFields(getRes.data, fields);
1341
- const putRes = await configPut("apps/tls", merged);
1342
- if (putRes.ok) return { kind: "ok" };
1343
- return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1467
+ const mergeRes = await configPatch("apps/tls", merged);
1468
+ if (mergeRes.ok) return { kind: "ok" };
1469
+ return { kind: "tool-error", result: bothErrors(label, patchRes, mergeRes, "PATCH apps/tls") };
1470
+ }
1471
+ async function setIssuerField(field, value, label) {
1472
+ const patchRes = await configPatch(`apps/tls/automation/policies/0/issuers/0/${field}`, value);
1473
+ const ok = { content: [{ type: "text", text: `${label} set to: ${value}` }] };
1474
+ if (patchRes.ok) return ok;
1475
+ const outcome = await safeFallback(label, patchRes, { [field]: value });
1476
+ return outcome.kind === "ok" ? ok : outcome.result;
1477
+ }
1478
+ function missingArgError(text) {
1479
+ return { isError: true, content: [{ type: "text", text: `Error: ${text}` }] };
1344
1480
  }
1345
1481
  function registerTlsTools(server) {
1346
1482
  server.tool(
1347
1483
  "caddy_tls",
1348
- "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. Works on both fresh and existing Caddy instances.",
1484
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
1349
1485
  {
1350
- action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
1486
+ action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1351
1487
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
1352
- ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
1488
+ ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
1489
+ profile: z5.string().optional().describe(
1490
+ "ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy."
1491
+ )
1353
1492
  },
1354
1493
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1355
- async ({ action, email, ca }) => {
1494
+ async ({ action, email, ca, profile }) => {
1356
1495
  if (action === "status") {
1357
1496
  return formatResult(await configGet("apps/tls"));
1358
1497
  }
1359
- if (action === "set_email") {
1360
- if (!email)
1498
+ if (action === "ech_status") {
1499
+ const echRes = await configGet("apps/tls/ech");
1500
+ const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
1501
+ if (absent) {
1361
1502
  return {
1362
- isError: true,
1363
- content: [{ type: "text", text: "Error: email is required for set_email action" }]
1503
+ content: [
1504
+ {
1505
+ type: "text",
1506
+ 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."
1507
+ }
1508
+ ]
1364
1509
  };
1365
- const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
1366
- if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1367
- const outcome = await safeFallback("ACME email", patchRes, { email });
1368
- if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1369
- return outcome.result;
1510
+ }
1511
+ return formatResult(echRes);
1512
+ }
1513
+ if (action === "set_email") {
1514
+ if (!email) return missingArgError("email is required for set_email action");
1515
+ return setIssuerField("email", email, "ACME email");
1370
1516
  }
1371
1517
  if (action === "set_acme_ca") {
1372
- if (!ca)
1373
- return {
1374
- isError: true,
1375
- content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
1376
- };
1377
- const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
1378
- if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1379
- const outcome = await safeFallback("ACME CA", patchRes, { ca });
1380
- if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1381
- return outcome.result;
1518
+ if (!ca) return missingArgError("ca is required for set_acme_ca action");
1519
+ return setIssuerField("ca", ca, "ACME CA");
1520
+ }
1521
+ if (action === "set_acme_profile") {
1522
+ if (!profile) return missingArgError("profile is required for set_acme_profile action");
1523
+ return setIssuerField("profile", profile, "ACME profile");
1382
1524
  }
1383
1525
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
1384
1526
  }
package/dist/server.js CHANGED
@@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/api.ts
7
+ import { request as httpRequest } from "http";
7
8
  var DEFAULT_URL = "http://localhost:2019";
8
9
  var TIMEOUT = 1e4;
9
10
  var RETRY_BASE_MS = 100;
@@ -43,6 +44,15 @@ function invalidateRelated(path) {
43
44
  function getBaseUrl() {
44
45
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
45
46
  }
47
+ function getUnixSocketPath() {
48
+ const raw = (process.env.CADDY_ADMIN_URL || "").trim();
49
+ if (!raw) return void 0;
50
+ const urlForm = /^unix:\/\/(\/.*)$/.exec(raw);
51
+ if (urlForm) return urlForm[1];
52
+ const caddyForm = /^unix\/(\/.*)$/.exec(raw);
53
+ if (caddyForm) return caddyForm[1];
54
+ return void 0;
55
+ }
46
56
  var warnedRetryClamp = false;
47
57
  function getMaxRetries() {
48
58
  const raw = process.env.CADDY_MAX_RETRIES;
@@ -63,11 +73,12 @@ function getAdminOrigin() {
63
73
  return void 0;
64
74
  }
65
75
  }
66
- function getHeaders(contentType) {
76
+ function getHeaders(contentType, overUnixSocket = false) {
67
77
  const headers = {};
68
78
  if (contentType) headers["Content-Type"] = contentType;
69
79
  const token = process.env.CADDY_API_TOKEN;
70
80
  if (token) headers.Authorization = `Bearer ${token}`;
81
+ if (overUnixSocket) return headers;
71
82
  const origin = getAdminOrigin();
72
83
  if (origin) headers.Origin = origin;
73
84
  return headers;
@@ -100,39 +111,85 @@ function isRetryableMethod(method, path) {
100
111
  if (method !== "POST") return true;
101
112
  return !path.startsWith("/config/") && !path.startsWith("/id/");
102
113
  }
103
- async function caddyRequest(method, path, body, contentType, timeout) {
114
+ function getMalformedUnixUrl() {
115
+ const raw = (process.env.CADDY_ADMIN_URL || "").trim();
116
+ if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
117
+ return getUnixSocketPath() === void 0 ? raw : void 0;
118
+ }
119
+ async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
120
+ const malformed = getMalformedUnixUrl();
121
+ if (malformed) {
122
+ return {
123
+ ok: false,
124
+ status: 0,
125
+ error: `CADDY_ADMIN_URL="${malformed}" looks like a unix socket address but is not a recognized form. Use "unix:///absolute/path.sock" (URL form) or "unix//absolute/path.sock" (Caddy's own spelling). A single slash after "unix:", or a relative path, will not parse.`
126
+ };
127
+ }
104
128
  const maxRetries = getMaxRetries();
105
129
  let attempt = 0;
106
- let res = await attemptRequest(method, path, body, contentType, timeout);
130
+ let res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
107
131
  while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
108
132
  attempt++;
109
133
  const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
110
134
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
111
135
  await sleep(delay);
112
- res = await attemptRequest(method, path, body, contentType, timeout);
136
+ res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
113
137
  }
114
138
  return res;
115
139
  }
116
- async function attemptRequest(method, path, body, contentType, timeout) {
140
+ function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
141
+ return new Promise((resolve, reject) => {
142
+ const req = httpRequest({ socketPath, path, method, headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
143
+ const chunks = [];
144
+ res.on("data", (chunk) => chunks.push(chunk));
145
+ res.on("error", reject);
146
+ res.on("end", () => {
147
+ const status = res.statusCode ?? 0;
148
+ const etag = res.headers.etag;
149
+ resolve({
150
+ ok: status >= 200 && status < 300,
151
+ status,
152
+ text: Buffer.concat(chunks).toString("utf8"),
153
+ etag: typeof etag === "string" ? etag : void 0
154
+ });
155
+ });
156
+ });
157
+ req.on("error", reject);
158
+ if (body !== void 0) req.write(body);
159
+ req.end();
160
+ });
161
+ }
162
+ async function sendViaFetch(url, method, headers, body, timeoutMs) {
163
+ const res = await fetch(url, {
164
+ method,
165
+ headers,
166
+ body,
167
+ signal: AbortSignal.timeout(timeoutMs)
168
+ });
169
+ return {
170
+ ok: res.ok,
171
+ status: res.status,
172
+ text: await res.text(),
173
+ etag: res.headers.get("ETag") || void 0
174
+ };
175
+ }
176
+ async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
177
+ const socketPath = getUnixSocketPath();
117
178
  const url = `${getBaseUrl()}${path}`;
118
179
  const effectiveTimeout = timeout ?? getRequestTimeout();
119
180
  try {
120
181
  const hasBody = body !== void 0;
121
- const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
182
+ const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
122
183
  const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
123
184
  const isWrite = method !== "GET";
124
185
  if (isWrite && isConfigPath) {
125
186
  const cachedEtag = etagCache.get(path);
126
187
  if (cachedEtag) headers["If-Match"] = cachedEtag;
127
188
  }
128
- const res = await fetch(url, {
129
- method,
130
- headers,
131
- body: hasBody ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
132
- signal: AbortSignal.timeout(effectiveTimeout)
133
- });
134
- const text = await res.text();
135
- const etag = res.headers.get("ETag") || void 0;
189
+ const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
190
+ const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
191
+ const text = res.text;
192
+ const etag = res.etag;
136
193
  if (method === "GET" && etag && isConfigPath) {
137
194
  setEtag(path, etag);
138
195
  }
@@ -156,10 +213,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
156
213
  return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
157
214
  }
158
215
  if (res.status === 403 && /origin/i.test(text)) {
216
+ const hint = socketPath ? `Over a unix socket caddy-mcp deliberately sends no Origin header, because Caddy builds no default origin allowlist for a unix listener (sending one would fail against an empty list). Reaching here means admin.enforce_origin is enabled -- disable it, or move the admin endpoint to a TCP address.` : `Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`;
159
217
  return {
160
218
  ok: false,
161
219
  status: 403,
162
- error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`
220
+ error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. ${hint}`
163
221
  };
164
222
  }
165
223
  return { ok: false, status: res.status, error: text };
@@ -172,17 +230,25 @@ async function attemptRequest(method, path, body, contentType, timeout) {
172
230
  }
173
231
  } catch (err) {
174
232
  const msg = err instanceof Error ? err.message : String(err);
233
+ if (socketPath && msg.includes("ENOENT")) {
234
+ return {
235
+ ok: false,
236
+ status: 0,
237
+ error: `No socket at ${socketPath} \u2014 check the path in CADDY_ADMIN_URL and that Caddy's admin endpoint is configured to listen on it.`
238
+ };
239
+ }
175
240
  if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
176
- const baseUrl = getBaseUrl();
177
- let origin = baseUrl;
178
- try {
179
- origin = new URL(baseUrl).origin;
180
- } catch {
241
+ let target = socketPath ?? getBaseUrl();
242
+ if (!socketPath) {
243
+ try {
244
+ target = new URL(target).origin;
245
+ } catch {
246
+ }
181
247
  }
182
248
  return {
183
249
  ok: false,
184
250
  status: 0,
185
- error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
251
+ error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
186
252
  };
187
253
  }
188
254
  if (msg.includes("abort") || msg.includes("timeout")) {
@@ -240,12 +306,12 @@ function getLoadTimeout() {
240
306
  return floored;
241
307
  }
242
308
  async function loadConfig(config, contentType) {
243
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
309
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
244
310
  if (res.ok) etagCache.clear();
245
311
  return res;
246
312
  }
247
313
  function adapt(config, adapter = "caddyfile") {
248
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
314
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
249
315
  }
250
316
  function stop() {
251
317
  return caddyRequest("POST", "/stop");
@@ -592,25 +658,83 @@ ${warnLines.join("\n")}` });
592
658
  import { z as z3 } from "zod";
593
659
 
594
660
  // src/snapshots.ts
661
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
662
+ import { join } from "path";
595
663
  var MAX_SNAPSHOTS = 10;
596
664
  var store = [];
665
+ function isSnapshotableConfig(data) {
666
+ return data !== null && typeof data === "object" && !Array.isArray(data);
667
+ }
668
+ var SNAPSHOT_FILE_RE = /^snapshot-(\d+)-([\w-]+)\.json$/;
669
+ function snapshotDir() {
670
+ const dir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
671
+ return dir ? dir : void 0;
672
+ }
673
+ var hydrated = false;
674
+ function hydrate() {
675
+ if (hydrated) return;
676
+ hydrated = true;
677
+ const dir = snapshotDir();
678
+ if (!dir || !existsSync(dir)) return;
679
+ try {
680
+ const names = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse().slice(0, MAX_SNAPSHOTS);
681
+ const loaded = [];
682
+ for (const name of names) {
683
+ const m = SNAPSHOT_FILE_RE.exec(name);
684
+ if (!m) continue;
685
+ try {
686
+ const config = JSON.parse(readFileSync(join(dir, name), "utf-8"));
687
+ if (!isSnapshotableConfig(config)) continue;
688
+ loaded.push({ config, timestamp: Number(m[1]), trigger: m[2] });
689
+ } catch {
690
+ }
691
+ }
692
+ loaded.sort((a, b) => b.timestamp - a.timestamp);
693
+ store.push(...loaded);
694
+ } catch {
695
+ }
696
+ }
697
+ function persist(snap) {
698
+ const dir = snapshotDir();
699
+ if (!dir) return;
700
+ try {
701
+ mkdirSync(dir, { recursive: true });
702
+ const safeTrigger = snap.trigger.replace(/[^\w-]/g, "_");
703
+ writeFileSync(
704
+ join(dir, `snapshot-${snap.timestamp}-${safeTrigger}.json`),
705
+ JSON.stringify(snap.config, null, 2),
706
+ "utf-8"
707
+ );
708
+ const files = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse();
709
+ for (const stale of files.slice(MAX_SNAPSHOTS)) {
710
+ rmSync(join(dir, stale), { force: true });
711
+ }
712
+ } catch {
713
+ }
714
+ }
597
715
  function saveSnapshot(config, trigger) {
598
- store.unshift({ config, timestamp: Date.now(), trigger });
716
+ hydrate();
717
+ let timestamp = Date.now();
718
+ if (store.length > 0 && timestamp <= store[0].timestamp) {
719
+ timestamp = store[0].timestamp + 1;
720
+ }
721
+ const snap = { config, timestamp, trigger };
722
+ store.unshift(snap);
599
723
  if (store.length > MAX_SNAPSHOTS) {
600
724
  store.length = MAX_SNAPSHOTS;
601
725
  }
726
+ persist(snap);
602
727
  }
603
728
  function listSnapshots() {
729
+ hydrate();
604
730
  return store;
605
731
  }
606
732
  function getSnapshot(index) {
733
+ hydrate();
607
734
  return store[index];
608
735
  }
609
736
 
610
737
  // src/tools/config.ts
611
- function isSnapshotableConfig(data) {
612
- return data !== null && typeof data === "object" && !Array.isArray(data);
613
- }
614
738
  function registerConfigTools(server) {
615
739
  server.tool(
616
740
  "caddy_config_get",
@@ -690,7 +814,7 @@ function registerConfigTools(server) {
690
814
  );
691
815
  server.tool(
692
816
  "caddy_revert",
693
- "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load and kept in-memory (last 10). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
817
+ "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (last 10). By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
694
818
  {
695
819
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
696
820
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -1233,6 +1357,7 @@ function buildTlsConfig(fields) {
1233
1357
  const issuer = { module: "acme" };
1234
1358
  if (fields.email) issuer.email = fields.email;
1235
1359
  if (fields.ca) issuer.ca = fields.ca;
1360
+ if (fields.profile) issuer.profile = fields.profile;
1236
1361
  return {
1237
1362
  automation: {
1238
1363
  policies: [{ issuers: [issuer] }]
@@ -1266,6 +1391,7 @@ function mergeIssuerFields(existing, fields) {
1266
1391
  const issuer = automation.policies[0].issuers[0];
1267
1392
  if (fields.email !== void 0) issuer.email = fields.email;
1268
1393
  if (fields.ca !== void 0) issuer.ca = fields.ca;
1394
+ if (fields.profile !== void 0) issuer.profile = fields.profile;
1269
1395
  return merged;
1270
1396
  }
1271
1397
  function validateIssuerShape(tls) {
@@ -1336,47 +1462,63 @@ async function safeFallback(label, patchRes, fields) {
1336
1462
  };
1337
1463
  }
1338
1464
  const merged = mergeIssuerFields(getRes.data, fields);
1339
- const putRes = await configPut("apps/tls", merged);
1340
- if (putRes.ok) return { kind: "ok" };
1341
- return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1465
+ const mergeRes = await configPatch("apps/tls", merged);
1466
+ if (mergeRes.ok) return { kind: "ok" };
1467
+ return { kind: "tool-error", result: bothErrors(label, patchRes, mergeRes, "PATCH apps/tls") };
1468
+ }
1469
+ async function setIssuerField(field, value, label) {
1470
+ const patchRes = await configPatch(`apps/tls/automation/policies/0/issuers/0/${field}`, value);
1471
+ const ok = { content: [{ type: "text", text: `${label} set to: ${value}` }] };
1472
+ if (patchRes.ok) return ok;
1473
+ const outcome = await safeFallback(label, patchRes, { [field]: value });
1474
+ return outcome.kind === "ok" ? ok : outcome.result;
1475
+ }
1476
+ function missingArgError(text) {
1477
+ return { isError: true, content: [{ type: "text", text: `Error: ${text}` }] };
1342
1478
  }
1343
1479
  function registerTlsTools(server) {
1344
1480
  server.tool(
1345
1481
  "caddy_tls",
1346
- "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. Works on both fresh and existing Caddy instances.",
1482
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
1347
1483
  {
1348
- action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
1484
+ action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1349
1485
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
1350
- ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
1486
+ ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
1487
+ profile: z5.string().optional().describe(
1488
+ "ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy."
1489
+ )
1351
1490
  },
1352
1491
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1353
- async ({ action, email, ca }) => {
1492
+ async ({ action, email, ca, profile }) => {
1354
1493
  if (action === "status") {
1355
1494
  return formatResult(await configGet("apps/tls"));
1356
1495
  }
1357
- if (action === "set_email") {
1358
- if (!email)
1496
+ if (action === "ech_status") {
1497
+ const echRes = await configGet("apps/tls/ech");
1498
+ const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
1499
+ if (absent) {
1359
1500
  return {
1360
- isError: true,
1361
- content: [{ type: "text", text: "Error: email is required for set_email action" }]
1501
+ content: [
1502
+ {
1503
+ type: "text",
1504
+ 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."
1505
+ }
1506
+ ]
1362
1507
  };
1363
- const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
1364
- if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1365
- const outcome = await safeFallback("ACME email", patchRes, { email });
1366
- if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1367
- return outcome.result;
1508
+ }
1509
+ return formatResult(echRes);
1510
+ }
1511
+ if (action === "set_email") {
1512
+ if (!email) return missingArgError("email is required for set_email action");
1513
+ return setIssuerField("email", email, "ACME email");
1368
1514
  }
1369
1515
  if (action === "set_acme_ca") {
1370
- if (!ca)
1371
- return {
1372
- isError: true,
1373
- content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
1374
- };
1375
- const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
1376
- if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1377
- const outcome = await safeFallback("ACME CA", patchRes, { ca });
1378
- if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1379
- return outcome.result;
1516
+ if (!ca) return missingArgError("ca is required for set_acme_ca action");
1517
+ return setIssuerField("ca", ca, "ACME CA");
1518
+ }
1519
+ if (action === "set_acme_profile") {
1520
+ if (!profile) return missingArgError("profile is required for set_acme_profile action");
1521
+ return setIssuerField("profile", profile, "ACME profile");
1380
1522
  }
1381
1523
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
1382
1524
  }
@@ -3,6 +3,16 @@ export interface Snapshot {
3
3
  timestamp: number;
4
4
  trigger: string;
5
5
  }
6
+ /**
7
+ * A snapshot must be re-applyable via /load, which requires a JSON object root.
8
+ * Strings, arrays, null, and primitives are not valid Caddy configs -- refuse to
9
+ * capture them so a later `caddy_revert apply` can't replay garbage.
10
+ *
11
+ * Exported because both entry points into the ring have to agree: the in-memory
12
+ * save path in tools/config.ts, and the rehydration path below. A file on disk
13
+ * that merely parses as JSON is not automatically a config.
14
+ */
15
+ export declare function isSnapshotableConfig(data: unknown): data is Record<string, unknown>;
6
16
  export declare function saveSnapshot(config: unknown, trigger: string): void;
7
17
  export declare function listSnapshots(): readonly Snapshot[];
8
18
  export declare function getSnapshot(index: number): Snapshot | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "MCP server for managing Caddy web servers via the admin API",
6
6
  "license": "MIT",