@yawlabs/caddy-mcp 0.3.0 → 1.0.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
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/caddy-mcp)](https://github.com/YawLabs/caddy-mcp/stargazers)
6
6
  [![CI](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
7
7
 
8
- **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 17 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics.
8
+ **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 18 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics, snapshots.
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -81,6 +81,7 @@ That's it. Now ask your AI assistant:
81
81
  |---|---|---|
82
82
  | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
83
83
  | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
84
+ | `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures (5xx, network errors). 4xx and 412 never retry. Hard-capped at 5. Set to `0` to disable. |
84
85
 
85
86
  **Alternate MCP clients:**
86
87
 
@@ -96,13 +97,14 @@ Use the same JSON block shown above in any of these.
96
97
 
97
98
  ## Tools
98
99
 
99
- ### Config management (5)
100
+ ### Config management (6)
100
101
 
101
102
  - **caddy_config_get** — Read config at any JSON path (or the full config).
102
103
  - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
103
104
  - **caddy_config_delete** — Delete config at a path.
104
105
  - **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths.
105
- - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning.
106
+ - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning. Auto-snapshots the prior config.
107
+ - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10.
106
108
 
107
109
  ### Route operations (4)
108
110
 
@@ -200,7 +202,7 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
200
202
 
201
203
  ## Requirements
202
204
 
203
- - Node.js 18+
205
+ - Node.js 20+
204
206
  - Caddy 2.x with admin API enabled (default: `localhost:2019`)
205
207
 
206
208
  ## Contributing
@@ -212,7 +214,7 @@ npm install
212
214
  npm run lint # Biome check
213
215
  npm run lint:fix # Auto-fix
214
216
  npm run build # tsup bundle
215
- npm test # Vitest (83 tests)
217
+ npm test # Vitest (106 unit tests; +7 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
216
218
  npm run typecheck # tsc --noEmit
217
219
  ```
218
220
 
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
8
8
  // src/api.ts
9
9
  var DEFAULT_URL = "http://localhost:2019";
10
10
  var TIMEOUT = 1e4;
11
+ var RETRY_BASE_MS = 100;
12
+ var RETRY_MAX_DELAY_MS = 2e3;
13
+ var RETRY_MAX_JITTER_MS = 50;
14
+ var RETRY_HARD_CAP = 5;
11
15
  var etagCache = /* @__PURE__ */ new Map();
12
16
  var MAX_ETAG_CACHE = 256;
13
17
  function setEtag(path, etag) {
@@ -20,6 +24,13 @@ function setEtag(path, etag) {
20
24
  function getBaseUrl() {
21
25
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
22
26
  }
27
+ function getMaxRetries() {
28
+ const raw = process.env.CADDY_MAX_RETRIES;
29
+ if (raw === void 0) return 2;
30
+ const n = Number(raw);
31
+ if (!Number.isFinite(n) || n < 0) return 2;
32
+ return Math.min(Math.floor(n), RETRY_HARD_CAP);
33
+ }
23
34
  function getHeaders(contentType) {
24
35
  const headers = {};
25
36
  if (contentType) headers["Content-Type"] = contentType;
@@ -30,7 +41,29 @@ function getHeaders(contentType) {
30
41
  function normalizePath(path) {
31
42
  return path.replace(/^\/?(config(\/|$))?/, "");
32
43
  }
44
+ function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
47
+ function isTransientFailure(res) {
48
+ if (res.ok) return false;
49
+ if (res.status === 0) return true;
50
+ if (res.status >= 500 && res.status <= 599) return true;
51
+ return false;
52
+ }
33
53
  async function caddyRequest(method, path, body, contentType, timeout) {
54
+ const maxRetries = getMaxRetries();
55
+ let attempt = 0;
56
+ let res = await attemptRequest(method, path, body, contentType, timeout);
57
+ while (isTransientFailure(res) && attempt < maxRetries) {
58
+ attempt++;
59
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
60
+ const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
61
+ await sleep(delay);
62
+ res = await attemptRequest(method, path, body, contentType, timeout);
63
+ }
64
+ return res;
65
+ }
66
+ async function attemptRequest(method, path, body, contentType, timeout) {
34
67
  const url = `${getBaseUrl()}${path}`;
35
68
  const effectiveTimeout = timeout ?? TIMEOUT;
36
69
  try {
@@ -235,6 +268,13 @@ function formatResult(res) {
235
268
  }
236
269
 
237
270
  // src/tools/adapt.ts
271
+ function formatWarning(w) {
272
+ if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
273
+ const obj = w;
274
+ const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
275
+ const message = typeof obj.message === "string" ? obj.message : JSON.stringify(w);
276
+ return ` - ${directive}: ${message}`;
277
+ }
238
278
  function registerAdaptTools(server) {
239
279
  server.tool(
240
280
  "caddy_adapt",
@@ -247,13 +287,12 @@ function registerAdaptTools(server) {
247
287
  async ({ config, adapter }) => {
248
288
  const res = await adapt(config, adapter);
249
289
  if (!res.ok) return formatResult(res);
250
- const warnings = res.data?.warnings || [];
251
- const result = res.data?.result;
290
+ const data = res.data ?? {};
291
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
292
+ const result = data.result;
252
293
  const content = [];
253
294
  if (warnings.length > 0) {
254
- const warnLines = warnings.map(
255
- (w) => ` - ${w.directive || "unknown"}: ${w.message || JSON.stringify(w)}`
256
- );
295
+ const warnLines = warnings.map(formatWarning);
257
296
  content.push({ type: "text", text: `Warnings:
258
297
  ${warnLines.join("\n")}` });
259
298
  }
@@ -268,6 +307,24 @@ ${warnLines.join("\n")}` });
268
307
 
269
308
  // src/tools/config.ts
270
309
  import { z as z2 } from "zod";
310
+
311
+ // src/snapshots.ts
312
+ var MAX_SNAPSHOTS = 10;
313
+ var store = [];
314
+ function saveSnapshot(config, trigger) {
315
+ store.unshift({ config, timestamp: Date.now(), trigger });
316
+ if (store.length > MAX_SNAPSHOTS) {
317
+ store.length = MAX_SNAPSHOTS;
318
+ }
319
+ }
320
+ function listSnapshots() {
321
+ return store;
322
+ }
323
+ function getSnapshot(index) {
324
+ return store[index];
325
+ }
326
+
327
+ // src/tools/config.ts
271
328
  function registerConfigTools(server) {
272
329
  server.tool(
273
330
  "caddy_config_get",
@@ -309,9 +366,79 @@ function registerConfigTools(server) {
309
366
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
310
367
  async ({ config, format }) => {
311
368
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
369
+ const current = await configGet();
370
+ if (current.ok && current.data !== void 0) {
371
+ saveSnapshot(current.data, "caddy_load");
372
+ }
312
373
  return formatResult(await loadConfig(config, contentType));
313
374
  }
314
375
  );
376
+ server.tool(
377
+ "caddy_revert",
378
+ "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).",
379
+ {
380
+ action: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
381
+ index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
382
+ confirm: z2.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
383
+ },
384
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
385
+ async ({ action, index, confirm }) => {
386
+ if (action === "list") {
387
+ const snaps = listSnapshots();
388
+ if (snaps.length === 0) {
389
+ return { content: [{ type: "text", text: "No snapshots available" }] };
390
+ }
391
+ const lines = snaps.map((s, i) => {
392
+ const when2 = new Date(s.timestamp).toISOString();
393
+ const size = JSON.stringify(s.config).length;
394
+ return ` [${i}] ${when2} trigger=${s.trigger} size=${size}B`;
395
+ });
396
+ return { content: [{ type: "text", text: `Snapshots:
397
+ ${lines.join("\n")}` }] };
398
+ }
399
+ if (action === "save") {
400
+ const current2 = await configGet();
401
+ if (!current2.ok) return formatResult(current2);
402
+ saveSnapshot(current2.data, "manual");
403
+ return { content: [{ type: "text", text: "Snapshot saved." }] };
404
+ }
405
+ if (!confirm) {
406
+ return {
407
+ isError: true,
408
+ content: [
409
+ {
410
+ type: "text",
411
+ text: `Refusing to apply snapshot [${index}] without confirm=true. Re-run with confirm:true to proceed.`
412
+ }
413
+ ]
414
+ };
415
+ }
416
+ const snap = getSnapshot(index);
417
+ if (!snap) {
418
+ return {
419
+ isError: true,
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `Error: no snapshot at index ${index}. Use action='list' to see available snapshots.`
424
+ }
425
+ ]
426
+ };
427
+ }
428
+ const current = await configGet();
429
+ if (current.ok && current.data !== void 0) {
430
+ saveSnapshot(current.data, "caddy_revert");
431
+ }
432
+ const res = await loadConfig(snap.config, "application/json");
433
+ if (!res.ok) return formatResult(res);
434
+ const when = new Date(snap.timestamp).toISOString();
435
+ return {
436
+ content: [
437
+ { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).` }
438
+ ]
439
+ };
440
+ }
441
+ );
315
442
  server.tool(
316
443
  "caddy_config_by_id",
317
444
  "Access config by @id tag. Any config object with an '@id' field can be read, updated, or deleted by its ID instead of needing its full path. This is the recommended way to manage individual routes and config objects.",
@@ -349,6 +476,29 @@ function registerConfigTools(server) {
349
476
 
350
477
  // src/tools/operational.ts
351
478
  import { z as z3 } from "zod";
479
+ function describeServer(raw) {
480
+ const listen = Array.isArray(raw.listen) ? raw.listen : [];
481
+ const routes = Array.isArray(raw.routes) ? raw.routes : [];
482
+ const hasExplicitTls = !!raw.tls_connection_policies;
483
+ const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
484
+ const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
485
+ const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
486
+ return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
487
+ }
488
+ function findAcmeEmail(policies) {
489
+ if (!Array.isArray(policies)) return void 0;
490
+ for (const rawPolicy of policies) {
491
+ if (!rawPolicy || typeof rawPolicy !== "object") continue;
492
+ const policy = rawPolicy;
493
+ if (!Array.isArray(policy.issuers)) continue;
494
+ for (const rawIssuer of policy.issuers) {
495
+ if (!rawIssuer || typeof rawIssuer !== "object") continue;
496
+ const issuer = rawIssuer;
497
+ if (typeof issuer.email === "string") return issuer.email;
498
+ }
499
+ }
500
+ return void 0;
501
+ }
352
502
  function registerOperationalTools(server) {
353
503
  server.tool(
354
504
  "caddy_status",
@@ -358,32 +508,20 @@ function registerOperationalTools(server) {
358
508
  async () => {
359
509
  const res = await configGet();
360
510
  if (!res.ok) return formatResult(res);
361
- const config = res.data || {};
362
- const httpApp = config?.apps?.http;
363
- const servers = httpApp?.servers || {};
511
+ const config = res.data ?? {};
512
+ const servers = config.apps?.http?.servers ?? {};
364
513
  const serverNames = Object.keys(servers);
365
514
  const lines = ["Caddy is running", ""];
366
515
  if (serverNames.length === 0) {
367
516
  lines.push("No HTTP servers configured");
368
517
  } else {
369
518
  for (const name of serverNames) {
370
- const srv = servers[name];
371
- const listen = srv.listen || [];
372
- const routes = srv.routes || [];
373
- const hasExplicitTls = !!srv.tls_connection_policies;
374
- const listensHttps = listen.some((l) => l.includes(":443"));
375
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
376
- lines.push(
377
- `Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
378
- );
519
+ lines.push(`Server "${name}": ${describeServer(servers[name])}`);
379
520
  }
380
521
  }
381
- const tlsApp = config?.apps?.tls;
382
- if (tlsApp?.automation?.policies) {
383
- const email = tlsApp.automation.policies.find((p) => p.issuers)?.issuers?.[0]?.email;
384
- if (email) lines.push(`
522
+ const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
523
+ if (email) lines.push(`
385
524
  ACME email: ${email}`);
386
- }
387
525
  return { content: [{ type: "text", text: lines.join("\n") }] };
388
526
  }
389
527
  );
@@ -395,21 +533,12 @@ ACME email: ${email}`);
395
533
  async () => {
396
534
  const res = await configGet("apps/http/servers");
397
535
  if (!res.ok) return formatResult(res);
398
- const servers = res.data || {};
536
+ const servers = res.data ?? {};
399
537
  const names = Object.keys(servers);
400
538
  if (names.length === 0) {
401
539
  return { content: [{ type: "text", text: "No HTTP servers configured" }] };
402
540
  }
403
- const lines = [];
404
- for (const name of names) {
405
- const srv = servers[name];
406
- const listen = srv.listen || [];
407
- const routes = srv.routes || [];
408
- const hasExplicitTls = !!srv.tls_connection_policies;
409
- const listensHttps = listen.some((l) => l.includes(":443"));
410
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
411
- lines.push(` ${name}: ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`);
412
- }
541
+ const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
413
542
  return {
414
543
  content: [{ type: "text", text: `HTTP Servers:
415
544
  ${lines.join("\n")}` }]
package/dist/server.js CHANGED
@@ -6,6 +6,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  // src/api.ts
7
7
  var DEFAULT_URL = "http://localhost:2019";
8
8
  var TIMEOUT = 1e4;
9
+ var RETRY_BASE_MS = 100;
10
+ var RETRY_MAX_DELAY_MS = 2e3;
11
+ var RETRY_MAX_JITTER_MS = 50;
12
+ var RETRY_HARD_CAP = 5;
9
13
  var etagCache = /* @__PURE__ */ new Map();
10
14
  var MAX_ETAG_CACHE = 256;
11
15
  function setEtag(path, etag) {
@@ -18,6 +22,13 @@ function setEtag(path, etag) {
18
22
  function getBaseUrl() {
19
23
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
20
24
  }
25
+ function getMaxRetries() {
26
+ const raw = process.env.CADDY_MAX_RETRIES;
27
+ if (raw === void 0) return 2;
28
+ const n = Number(raw);
29
+ if (!Number.isFinite(n) || n < 0) return 2;
30
+ return Math.min(Math.floor(n), RETRY_HARD_CAP);
31
+ }
21
32
  function getHeaders(contentType) {
22
33
  const headers = {};
23
34
  if (contentType) headers["Content-Type"] = contentType;
@@ -28,7 +39,29 @@ function getHeaders(contentType) {
28
39
  function normalizePath(path) {
29
40
  return path.replace(/^\/?(config(\/|$))?/, "");
30
41
  }
42
+ function sleep(ms) {
43
+ return new Promise((resolve) => setTimeout(resolve, ms));
44
+ }
45
+ function isTransientFailure(res) {
46
+ if (res.ok) return false;
47
+ if (res.status === 0) return true;
48
+ if (res.status >= 500 && res.status <= 599) return true;
49
+ return false;
50
+ }
31
51
  async function caddyRequest(method, path, body, contentType, timeout) {
52
+ const maxRetries = getMaxRetries();
53
+ let attempt = 0;
54
+ let res = await attemptRequest(method, path, body, contentType, timeout);
55
+ while (isTransientFailure(res) && attempt < maxRetries) {
56
+ attempt++;
57
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
58
+ const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
59
+ await sleep(delay);
60
+ res = await attemptRequest(method, path, body, contentType, timeout);
61
+ }
62
+ return res;
63
+ }
64
+ async function attemptRequest(method, path, body, contentType, timeout) {
32
65
  const url = `${getBaseUrl()}${path}`;
33
66
  const effectiveTimeout = timeout ?? TIMEOUT;
34
67
  try {
@@ -233,6 +266,13 @@ function formatResult(res) {
233
266
  }
234
267
 
235
268
  // src/tools/adapt.ts
269
+ function formatWarning(w) {
270
+ if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
271
+ const obj = w;
272
+ const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
273
+ const message = typeof obj.message === "string" ? obj.message : JSON.stringify(w);
274
+ return ` - ${directive}: ${message}`;
275
+ }
236
276
  function registerAdaptTools(server) {
237
277
  server.tool(
238
278
  "caddy_adapt",
@@ -245,13 +285,12 @@ function registerAdaptTools(server) {
245
285
  async ({ config, adapter }) => {
246
286
  const res = await adapt(config, adapter);
247
287
  if (!res.ok) return formatResult(res);
248
- const warnings = res.data?.warnings || [];
249
- const result = res.data?.result;
288
+ const data = res.data ?? {};
289
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
290
+ const result = data.result;
250
291
  const content = [];
251
292
  if (warnings.length > 0) {
252
- const warnLines = warnings.map(
253
- (w) => ` - ${w.directive || "unknown"}: ${w.message || JSON.stringify(w)}`
254
- );
293
+ const warnLines = warnings.map(formatWarning);
255
294
  content.push({ type: "text", text: `Warnings:
256
295
  ${warnLines.join("\n")}` });
257
296
  }
@@ -266,6 +305,24 @@ ${warnLines.join("\n")}` });
266
305
 
267
306
  // src/tools/config.ts
268
307
  import { z as z2 } from "zod";
308
+
309
+ // src/snapshots.ts
310
+ var MAX_SNAPSHOTS = 10;
311
+ var store = [];
312
+ function saveSnapshot(config, trigger) {
313
+ store.unshift({ config, timestamp: Date.now(), trigger });
314
+ if (store.length > MAX_SNAPSHOTS) {
315
+ store.length = MAX_SNAPSHOTS;
316
+ }
317
+ }
318
+ function listSnapshots() {
319
+ return store;
320
+ }
321
+ function getSnapshot(index) {
322
+ return store[index];
323
+ }
324
+
325
+ // src/tools/config.ts
269
326
  function registerConfigTools(server) {
270
327
  server.tool(
271
328
  "caddy_config_get",
@@ -307,9 +364,79 @@ function registerConfigTools(server) {
307
364
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
308
365
  async ({ config, format }) => {
309
366
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
367
+ const current = await configGet();
368
+ if (current.ok && current.data !== void 0) {
369
+ saveSnapshot(current.data, "caddy_load");
370
+ }
310
371
  return formatResult(await loadConfig(config, contentType));
311
372
  }
312
373
  );
374
+ server.tool(
375
+ "caddy_revert",
376
+ "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).",
377
+ {
378
+ action: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
379
+ index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
380
+ confirm: z2.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
381
+ },
382
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
383
+ async ({ action, index, confirm }) => {
384
+ if (action === "list") {
385
+ const snaps = listSnapshots();
386
+ if (snaps.length === 0) {
387
+ return { content: [{ type: "text", text: "No snapshots available" }] };
388
+ }
389
+ const lines = snaps.map((s, i) => {
390
+ const when2 = new Date(s.timestamp).toISOString();
391
+ const size = JSON.stringify(s.config).length;
392
+ return ` [${i}] ${when2} trigger=${s.trigger} size=${size}B`;
393
+ });
394
+ return { content: [{ type: "text", text: `Snapshots:
395
+ ${lines.join("\n")}` }] };
396
+ }
397
+ if (action === "save") {
398
+ const current2 = await configGet();
399
+ if (!current2.ok) return formatResult(current2);
400
+ saveSnapshot(current2.data, "manual");
401
+ return { content: [{ type: "text", text: "Snapshot saved." }] };
402
+ }
403
+ if (!confirm) {
404
+ return {
405
+ isError: true,
406
+ content: [
407
+ {
408
+ type: "text",
409
+ text: `Refusing to apply snapshot [${index}] without confirm=true. Re-run with confirm:true to proceed.`
410
+ }
411
+ ]
412
+ };
413
+ }
414
+ const snap = getSnapshot(index);
415
+ if (!snap) {
416
+ return {
417
+ isError: true,
418
+ content: [
419
+ {
420
+ type: "text",
421
+ text: `Error: no snapshot at index ${index}. Use action='list' to see available snapshots.`
422
+ }
423
+ ]
424
+ };
425
+ }
426
+ const current = await configGet();
427
+ if (current.ok && current.data !== void 0) {
428
+ saveSnapshot(current.data, "caddy_revert");
429
+ }
430
+ const res = await loadConfig(snap.config, "application/json");
431
+ if (!res.ok) return formatResult(res);
432
+ const when = new Date(snap.timestamp).toISOString();
433
+ return {
434
+ content: [
435
+ { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).` }
436
+ ]
437
+ };
438
+ }
439
+ );
313
440
  server.tool(
314
441
  "caddy_config_by_id",
315
442
  "Access config by @id tag. Any config object with an '@id' field can be read, updated, or deleted by its ID instead of needing its full path. This is the recommended way to manage individual routes and config objects.",
@@ -347,6 +474,29 @@ function registerConfigTools(server) {
347
474
 
348
475
  // src/tools/operational.ts
349
476
  import { z as z3 } from "zod";
477
+ function describeServer(raw) {
478
+ const listen = Array.isArray(raw.listen) ? raw.listen : [];
479
+ const routes = Array.isArray(raw.routes) ? raw.routes : [];
480
+ const hasExplicitTls = !!raw.tls_connection_policies;
481
+ const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
482
+ const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
483
+ const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
484
+ return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
485
+ }
486
+ function findAcmeEmail(policies) {
487
+ if (!Array.isArray(policies)) return void 0;
488
+ for (const rawPolicy of policies) {
489
+ if (!rawPolicy || typeof rawPolicy !== "object") continue;
490
+ const policy = rawPolicy;
491
+ if (!Array.isArray(policy.issuers)) continue;
492
+ for (const rawIssuer of policy.issuers) {
493
+ if (!rawIssuer || typeof rawIssuer !== "object") continue;
494
+ const issuer = rawIssuer;
495
+ if (typeof issuer.email === "string") return issuer.email;
496
+ }
497
+ }
498
+ return void 0;
499
+ }
350
500
  function registerOperationalTools(server) {
351
501
  server.tool(
352
502
  "caddy_status",
@@ -356,32 +506,20 @@ function registerOperationalTools(server) {
356
506
  async () => {
357
507
  const res = await configGet();
358
508
  if (!res.ok) return formatResult(res);
359
- const config = res.data || {};
360
- const httpApp = config?.apps?.http;
361
- const servers = httpApp?.servers || {};
509
+ const config = res.data ?? {};
510
+ const servers = config.apps?.http?.servers ?? {};
362
511
  const serverNames = Object.keys(servers);
363
512
  const lines = ["Caddy is running", ""];
364
513
  if (serverNames.length === 0) {
365
514
  lines.push("No HTTP servers configured");
366
515
  } else {
367
516
  for (const name of serverNames) {
368
- const srv = servers[name];
369
- const listen = srv.listen || [];
370
- const routes = srv.routes || [];
371
- const hasExplicitTls = !!srv.tls_connection_policies;
372
- const listensHttps = listen.some((l) => l.includes(":443"));
373
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
374
- lines.push(
375
- `Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
376
- );
517
+ lines.push(`Server "${name}": ${describeServer(servers[name])}`);
377
518
  }
378
519
  }
379
- const tlsApp = config?.apps?.tls;
380
- if (tlsApp?.automation?.policies) {
381
- const email = tlsApp.automation.policies.find((p) => p.issuers)?.issuers?.[0]?.email;
382
- if (email) lines.push(`
520
+ const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
521
+ if (email) lines.push(`
383
522
  ACME email: ${email}`);
384
- }
385
523
  return { content: [{ type: "text", text: lines.join("\n") }] };
386
524
  }
387
525
  );
@@ -393,21 +531,12 @@ ACME email: ${email}`);
393
531
  async () => {
394
532
  const res = await configGet("apps/http/servers");
395
533
  if (!res.ok) return formatResult(res);
396
- const servers = res.data || {};
534
+ const servers = res.data ?? {};
397
535
  const names = Object.keys(servers);
398
536
  if (names.length === 0) {
399
537
  return { content: [{ type: "text", text: "No HTTP servers configured" }] };
400
538
  }
401
- const lines = [];
402
- for (const name of names) {
403
- const srv = servers[name];
404
- const listen = srv.listen || [];
405
- const routes = srv.routes || [];
406
- const hasExplicitTls = !!srv.tls_connection_policies;
407
- const listensHttps = listen.some((l) => l.includes(":443"));
408
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
409
- lines.push(` ${name}: ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`);
410
- }
539
+ const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
411
540
  return {
412
541
  content: [{ type: "text", text: `HTTP Servers:
413
542
  ${lines.join("\n")}` }]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "MCP server for managing Caddy web servers via the admin API",
5
5
  "license": "MIT",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
@@ -38,6 +38,10 @@
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
39
  "zod": "^4.3.6"
40
40
  },
41
+ "overrides": {
42
+ "hono": "^4.12.14",
43
+ "@hono/node-server": "^1.19.13"
44
+ },
41
45
  "devDependencies": {
42
46
  "@biomejs/biome": "^2.4.11",
43
47
  "@types/node": "^25.6.0",
@@ -46,7 +50,7 @@
46
50
  "vitest": "^4.1.4"
47
51
  },
48
52
  "engines": {
49
- "node": ">=18"
53
+ "node": ">=20"
50
54
  },
51
55
  "keywords": [
52
56
  "mcp",