@yawlabs/caddy-mcp 0.3.1 → 1.0.1

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
 
@@ -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,39 @@ function getHeaders(contentType) {
30
41
  function normalizePath(path) {
31
42
  return path.replace(/^\/?(config(\/|$))?/, "");
32
43
  }
44
+ function rejectTraversal(path) {
45
+ if (/(^|\/)\.\.(\/|$)/.test(path)) {
46
+ return {
47
+ ok: false,
48
+ status: 0,
49
+ error: `Invalid path "${path}": '..' segments are not allowed`
50
+ };
51
+ }
52
+ return null;
53
+ }
54
+ function sleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
57
+ function isTransientFailure(res) {
58
+ if (res.ok) return false;
59
+ if (res.status === 0) return true;
60
+ if (res.status >= 500 && res.status <= 599) return true;
61
+ return false;
62
+ }
33
63
  async function caddyRequest(method, path, body, contentType, timeout) {
64
+ const maxRetries = getMaxRetries();
65
+ let attempt = 0;
66
+ let res = await attemptRequest(method, path, body, contentType, timeout);
67
+ while (isTransientFailure(res) && attempt < maxRetries) {
68
+ attempt++;
69
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
70
+ const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
71
+ await sleep(delay);
72
+ res = await attemptRequest(method, path, body, contentType, timeout);
73
+ }
74
+ return res;
75
+ }
76
+ async function attemptRequest(method, path, body, contentType, timeout) {
34
77
  const url = `${getBaseUrl()}${path}`;
35
78
  const effectiveTimeout = timeout ?? TIMEOUT;
36
79
  try {
@@ -96,22 +139,32 @@ async function caddyRequest(method, path, body, contentType, timeout) {
96
139
  }
97
140
  function configGet(path = "") {
98
141
  const normalized = normalizePath(path);
142
+ const bad = rejectTraversal(normalized);
143
+ if (bad) return Promise.resolve(bad);
99
144
  return caddyRequest("GET", `/config/${normalized}`);
100
145
  }
101
146
  function configPost(path, value) {
102
147
  const normalized = normalizePath(path);
148
+ const bad = rejectTraversal(normalized);
149
+ if (bad) return Promise.resolve(bad);
103
150
  return caddyRequest("POST", `/config/${normalized}`, value);
104
151
  }
105
152
  function configPut(path, value) {
106
153
  const normalized = normalizePath(path);
154
+ const bad = rejectTraversal(normalized);
155
+ if (bad) return Promise.resolve(bad);
107
156
  return caddyRequest("PUT", `/config/${normalized}`, value);
108
157
  }
109
158
  function configPatch(path, value) {
110
159
  const normalized = normalizePath(path);
160
+ const bad = rejectTraversal(normalized);
161
+ if (bad) return Promise.resolve(bad);
111
162
  return caddyRequest("PATCH", `/config/${normalized}`, value);
112
163
  }
113
164
  function configDelete(path) {
114
165
  const normalized = normalizePath(path);
166
+ const bad = rejectTraversal(normalized);
167
+ if (bad) return Promise.resolve(bad);
115
168
  return caddyRequest("DELETE", `/config/${normalized}`);
116
169
  }
117
170
  var LOAD_TIMEOUT = 6e4;
@@ -136,14 +189,20 @@ function getPkiCertificates(ca = "local") {
136
189
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
137
190
  }
138
191
  function configByIdGet(id, subpath = "") {
192
+ const bad = rejectTraversal(subpath);
193
+ if (bad) return Promise.resolve(bad);
139
194
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
140
195
  return caddyRequest("GET", path);
141
196
  }
142
197
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
198
+ const bad = rejectTraversal(subpath);
199
+ if (bad) return Promise.resolve(bad);
143
200
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
144
201
  return caddyRequest(method, path, value);
145
202
  }
146
203
  function configByIdDelete(id, subpath = "") {
204
+ const bad = rejectTraversal(subpath);
205
+ if (bad) return Promise.resolve(bad);
147
206
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
148
207
  return caddyRequest("DELETE", path);
149
208
  }
@@ -235,6 +294,13 @@ function formatResult(res) {
235
294
  }
236
295
 
237
296
  // src/tools/adapt.ts
297
+ function formatWarning(w) {
298
+ if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
299
+ const obj = w;
300
+ const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
301
+ const message = typeof obj.message === "string" ? obj.message : JSON.stringify(w);
302
+ return ` - ${directive}: ${message}`;
303
+ }
238
304
  function registerAdaptTools(server) {
239
305
  server.tool(
240
306
  "caddy_adapt",
@@ -247,13 +313,12 @@ function registerAdaptTools(server) {
247
313
  async ({ config, adapter }) => {
248
314
  const res = await adapt(config, adapter);
249
315
  if (!res.ok) return formatResult(res);
250
- const warnings = res.data?.warnings || [];
251
- const result = res.data?.result;
316
+ const data = res.data ?? {};
317
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
318
+ const result = data.result;
252
319
  const content = [];
253
320
  if (warnings.length > 0) {
254
- const warnLines = warnings.map(
255
- (w) => ` - ${w.directive || "unknown"}: ${w.message || JSON.stringify(w)}`
256
- );
321
+ const warnLines = warnings.map(formatWarning);
257
322
  content.push({ type: "text", text: `Warnings:
258
323
  ${warnLines.join("\n")}` });
259
324
  }
@@ -268,6 +333,24 @@ ${warnLines.join("\n")}` });
268
333
 
269
334
  // src/tools/config.ts
270
335
  import { z as z2 } from "zod";
336
+
337
+ // src/snapshots.ts
338
+ var MAX_SNAPSHOTS = 10;
339
+ var store = [];
340
+ function saveSnapshot(config, trigger) {
341
+ store.unshift({ config, timestamp: Date.now(), trigger });
342
+ if (store.length > MAX_SNAPSHOTS) {
343
+ store.length = MAX_SNAPSHOTS;
344
+ }
345
+ }
346
+ function listSnapshots() {
347
+ return store;
348
+ }
349
+ function getSnapshot(index) {
350
+ return store[index];
351
+ }
352
+
353
+ // src/tools/config.ts
271
354
  function registerConfigTools(server) {
272
355
  server.tool(
273
356
  "caddy_config_get",
@@ -309,9 +392,85 @@ function registerConfigTools(server) {
309
392
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
310
393
  async ({ config, format }) => {
311
394
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
395
+ const current = await configGet();
396
+ if (current.ok && current.data !== void 0) {
397
+ saveSnapshot(current.data, "caddy_load");
398
+ }
312
399
  return formatResult(await loadConfig(config, contentType));
313
400
  }
314
401
  );
402
+ server.tool(
403
+ "caddy_revert",
404
+ "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).",
405
+ {
406
+ action: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
407
+ index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
408
+ confirm: z2.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
409
+ },
410
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
411
+ async ({ action, index, confirm }) => {
412
+ if (action === "list") {
413
+ const snaps = listSnapshots();
414
+ if (snaps.length === 0) {
415
+ return { content: [{ type: "text", text: "No snapshots available" }] };
416
+ }
417
+ const lines = snaps.map((s, i) => {
418
+ const when2 = new Date(s.timestamp).toISOString();
419
+ const size = JSON.stringify(s.config).length;
420
+ return ` [${i}] ${when2} trigger=${s.trigger} size=${size}B`;
421
+ });
422
+ return { content: [{ type: "text", text: `Snapshots:
423
+ ${lines.join("\n")}` }] };
424
+ }
425
+ if (action === "save") {
426
+ const current2 = await configGet();
427
+ if (!current2.ok) return formatResult(current2);
428
+ if (current2.data === void 0) {
429
+ return {
430
+ isError: true,
431
+ content: [{ type: "text", text: "Error: no config loaded to snapshot" }]
432
+ };
433
+ }
434
+ saveSnapshot(current2.data, "manual");
435
+ return { content: [{ type: "text", text: "Snapshot saved." }] };
436
+ }
437
+ if (!confirm) {
438
+ return {
439
+ isError: true,
440
+ content: [
441
+ {
442
+ type: "text",
443
+ text: `Refusing to apply snapshot [${index}] without confirm=true. Re-run with confirm:true to proceed.`
444
+ }
445
+ ]
446
+ };
447
+ }
448
+ const snap = getSnapshot(index);
449
+ if (!snap) {
450
+ return {
451
+ isError: true,
452
+ content: [
453
+ {
454
+ type: "text",
455
+ text: `Error: no snapshot at index ${index}. Use action='list' to see available snapshots.`
456
+ }
457
+ ]
458
+ };
459
+ }
460
+ const current = await configGet();
461
+ if (current.ok && current.data !== void 0) {
462
+ saveSnapshot(current.data, "caddy_revert");
463
+ }
464
+ const res = await loadConfig(snap.config, "application/json");
465
+ if (!res.ok) return formatResult(res);
466
+ const when = new Date(snap.timestamp).toISOString();
467
+ return {
468
+ content: [
469
+ { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).` }
470
+ ]
471
+ };
472
+ }
473
+ );
315
474
  server.tool(
316
475
  "caddy_config_by_id",
317
476
  "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 +508,29 @@ function registerConfigTools(server) {
349
508
 
350
509
  // src/tools/operational.ts
351
510
  import { z as z3 } from "zod";
511
+ function describeServer(raw) {
512
+ const listen = Array.isArray(raw.listen) ? raw.listen : [];
513
+ const routes = Array.isArray(raw.routes) ? raw.routes : [];
514
+ const hasExplicitTls = !!raw.tls_connection_policies;
515
+ const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
516
+ const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
517
+ const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
518
+ return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
519
+ }
520
+ function findAcmeEmail(policies) {
521
+ if (!Array.isArray(policies)) return void 0;
522
+ for (const rawPolicy of policies) {
523
+ if (!rawPolicy || typeof rawPolicy !== "object") continue;
524
+ const policy = rawPolicy;
525
+ if (!Array.isArray(policy.issuers)) continue;
526
+ for (const rawIssuer of policy.issuers) {
527
+ if (!rawIssuer || typeof rawIssuer !== "object") continue;
528
+ const issuer = rawIssuer;
529
+ if (typeof issuer.email === "string") return issuer.email;
530
+ }
531
+ }
532
+ return void 0;
533
+ }
352
534
  function registerOperationalTools(server) {
353
535
  server.tool(
354
536
  "caddy_status",
@@ -358,32 +540,20 @@ function registerOperationalTools(server) {
358
540
  async () => {
359
541
  const res = await configGet();
360
542
  if (!res.ok) return formatResult(res);
361
- const config = res.data || {};
362
- const httpApp = config?.apps?.http;
363
- const servers = httpApp?.servers || {};
543
+ const config = res.data ?? {};
544
+ const servers = config.apps?.http?.servers ?? {};
364
545
  const serverNames = Object.keys(servers);
365
546
  const lines = ["Caddy is running", ""];
366
547
  if (serverNames.length === 0) {
367
548
  lines.push("No HTTP servers configured");
368
549
  } else {
369
550
  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
- );
551
+ lines.push(`Server "${name}": ${describeServer(servers[name])}`);
379
552
  }
380
553
  }
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(`
554
+ const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
555
+ if (email) lines.push(`
385
556
  ACME email: ${email}`);
386
- }
387
557
  return { content: [{ type: "text", text: lines.join("\n") }] };
388
558
  }
389
559
  );
@@ -395,21 +565,12 @@ ACME email: ${email}`);
395
565
  async () => {
396
566
  const res = await configGet("apps/http/servers");
397
567
  if (!res.ok) return formatResult(res);
398
- const servers = res.data || {};
568
+ const servers = res.data ?? {};
399
569
  const names = Object.keys(servers);
400
570
  if (names.length === 0) {
401
571
  return { content: [{ type: "text", text: "No HTTP servers configured" }] };
402
572
  }
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
- }
573
+ const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
413
574
  return {
414
575
  content: [{ type: "text", text: `HTTP Servers:
415
576
  ${lines.join("\n")}` }]
@@ -680,7 +841,7 @@ function registerRouteTools(server) {
680
841
  );
681
842
  server.tool(
682
843
  "caddy_remove_route",
683
- "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
844
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
684
845
  {
685
846
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
686
847
  index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
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,39 @@ function getHeaders(contentType) {
28
39
  function normalizePath(path) {
29
40
  return path.replace(/^\/?(config(\/|$))?/, "");
30
41
  }
42
+ function rejectTraversal(path) {
43
+ if (/(^|\/)\.\.(\/|$)/.test(path)) {
44
+ return {
45
+ ok: false,
46
+ status: 0,
47
+ error: `Invalid path "${path}": '..' segments are not allowed`
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+ function sleep(ms) {
53
+ return new Promise((resolve) => setTimeout(resolve, ms));
54
+ }
55
+ function isTransientFailure(res) {
56
+ if (res.ok) return false;
57
+ if (res.status === 0) return true;
58
+ if (res.status >= 500 && res.status <= 599) return true;
59
+ return false;
60
+ }
31
61
  async function caddyRequest(method, path, body, contentType, timeout) {
62
+ const maxRetries = getMaxRetries();
63
+ let attempt = 0;
64
+ let res = await attemptRequest(method, path, body, contentType, timeout);
65
+ while (isTransientFailure(res) && attempt < maxRetries) {
66
+ attempt++;
67
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
68
+ const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
69
+ await sleep(delay);
70
+ res = await attemptRequest(method, path, body, contentType, timeout);
71
+ }
72
+ return res;
73
+ }
74
+ async function attemptRequest(method, path, body, contentType, timeout) {
32
75
  const url = `${getBaseUrl()}${path}`;
33
76
  const effectiveTimeout = timeout ?? TIMEOUT;
34
77
  try {
@@ -94,22 +137,32 @@ async function caddyRequest(method, path, body, contentType, timeout) {
94
137
  }
95
138
  function configGet(path = "") {
96
139
  const normalized = normalizePath(path);
140
+ const bad = rejectTraversal(normalized);
141
+ if (bad) return Promise.resolve(bad);
97
142
  return caddyRequest("GET", `/config/${normalized}`);
98
143
  }
99
144
  function configPost(path, value) {
100
145
  const normalized = normalizePath(path);
146
+ const bad = rejectTraversal(normalized);
147
+ if (bad) return Promise.resolve(bad);
101
148
  return caddyRequest("POST", `/config/${normalized}`, value);
102
149
  }
103
150
  function configPut(path, value) {
104
151
  const normalized = normalizePath(path);
152
+ const bad = rejectTraversal(normalized);
153
+ if (bad) return Promise.resolve(bad);
105
154
  return caddyRequest("PUT", `/config/${normalized}`, value);
106
155
  }
107
156
  function configPatch(path, value) {
108
157
  const normalized = normalizePath(path);
158
+ const bad = rejectTraversal(normalized);
159
+ if (bad) return Promise.resolve(bad);
109
160
  return caddyRequest("PATCH", `/config/${normalized}`, value);
110
161
  }
111
162
  function configDelete(path) {
112
163
  const normalized = normalizePath(path);
164
+ const bad = rejectTraversal(normalized);
165
+ if (bad) return Promise.resolve(bad);
113
166
  return caddyRequest("DELETE", `/config/${normalized}`);
114
167
  }
115
168
  var LOAD_TIMEOUT = 6e4;
@@ -134,14 +187,20 @@ function getPkiCertificates(ca = "local") {
134
187
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
135
188
  }
136
189
  function configByIdGet(id, subpath = "") {
190
+ const bad = rejectTraversal(subpath);
191
+ if (bad) return Promise.resolve(bad);
137
192
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
138
193
  return caddyRequest("GET", path);
139
194
  }
140
195
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
196
+ const bad = rejectTraversal(subpath);
197
+ if (bad) return Promise.resolve(bad);
141
198
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
142
199
  return caddyRequest(method, path, value);
143
200
  }
144
201
  function configByIdDelete(id, subpath = "") {
202
+ const bad = rejectTraversal(subpath);
203
+ if (bad) return Promise.resolve(bad);
145
204
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
146
205
  return caddyRequest("DELETE", path);
147
206
  }
@@ -233,6 +292,13 @@ function formatResult(res) {
233
292
  }
234
293
 
235
294
  // src/tools/adapt.ts
295
+ function formatWarning(w) {
296
+ if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
297
+ const obj = w;
298
+ const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
299
+ const message = typeof obj.message === "string" ? obj.message : JSON.stringify(w);
300
+ return ` - ${directive}: ${message}`;
301
+ }
236
302
  function registerAdaptTools(server) {
237
303
  server.tool(
238
304
  "caddy_adapt",
@@ -245,13 +311,12 @@ function registerAdaptTools(server) {
245
311
  async ({ config, adapter }) => {
246
312
  const res = await adapt(config, adapter);
247
313
  if (!res.ok) return formatResult(res);
248
- const warnings = res.data?.warnings || [];
249
- const result = res.data?.result;
314
+ const data = res.data ?? {};
315
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
316
+ const result = data.result;
250
317
  const content = [];
251
318
  if (warnings.length > 0) {
252
- const warnLines = warnings.map(
253
- (w) => ` - ${w.directive || "unknown"}: ${w.message || JSON.stringify(w)}`
254
- );
319
+ const warnLines = warnings.map(formatWarning);
255
320
  content.push({ type: "text", text: `Warnings:
256
321
  ${warnLines.join("\n")}` });
257
322
  }
@@ -266,6 +331,24 @@ ${warnLines.join("\n")}` });
266
331
 
267
332
  // src/tools/config.ts
268
333
  import { z as z2 } from "zod";
334
+
335
+ // src/snapshots.ts
336
+ var MAX_SNAPSHOTS = 10;
337
+ var store = [];
338
+ function saveSnapshot(config, trigger) {
339
+ store.unshift({ config, timestamp: Date.now(), trigger });
340
+ if (store.length > MAX_SNAPSHOTS) {
341
+ store.length = MAX_SNAPSHOTS;
342
+ }
343
+ }
344
+ function listSnapshots() {
345
+ return store;
346
+ }
347
+ function getSnapshot(index) {
348
+ return store[index];
349
+ }
350
+
351
+ // src/tools/config.ts
269
352
  function registerConfigTools(server) {
270
353
  server.tool(
271
354
  "caddy_config_get",
@@ -307,9 +390,85 @@ function registerConfigTools(server) {
307
390
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
308
391
  async ({ config, format }) => {
309
392
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
393
+ const current = await configGet();
394
+ if (current.ok && current.data !== void 0) {
395
+ saveSnapshot(current.data, "caddy_load");
396
+ }
310
397
  return formatResult(await loadConfig(config, contentType));
311
398
  }
312
399
  );
400
+ server.tool(
401
+ "caddy_revert",
402
+ "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).",
403
+ {
404
+ action: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
405
+ index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
406
+ confirm: z2.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
407
+ },
408
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
409
+ async ({ action, index, confirm }) => {
410
+ if (action === "list") {
411
+ const snaps = listSnapshots();
412
+ if (snaps.length === 0) {
413
+ return { content: [{ type: "text", text: "No snapshots available" }] };
414
+ }
415
+ const lines = snaps.map((s, i) => {
416
+ const when2 = new Date(s.timestamp).toISOString();
417
+ const size = JSON.stringify(s.config).length;
418
+ return ` [${i}] ${when2} trigger=${s.trigger} size=${size}B`;
419
+ });
420
+ return { content: [{ type: "text", text: `Snapshots:
421
+ ${lines.join("\n")}` }] };
422
+ }
423
+ if (action === "save") {
424
+ const current2 = await configGet();
425
+ if (!current2.ok) return formatResult(current2);
426
+ if (current2.data === void 0) {
427
+ return {
428
+ isError: true,
429
+ content: [{ type: "text", text: "Error: no config loaded to snapshot" }]
430
+ };
431
+ }
432
+ saveSnapshot(current2.data, "manual");
433
+ return { content: [{ type: "text", text: "Snapshot saved." }] };
434
+ }
435
+ if (!confirm) {
436
+ return {
437
+ isError: true,
438
+ content: [
439
+ {
440
+ type: "text",
441
+ text: `Refusing to apply snapshot [${index}] without confirm=true. Re-run with confirm:true to proceed.`
442
+ }
443
+ ]
444
+ };
445
+ }
446
+ const snap = getSnapshot(index);
447
+ if (!snap) {
448
+ return {
449
+ isError: true,
450
+ content: [
451
+ {
452
+ type: "text",
453
+ text: `Error: no snapshot at index ${index}. Use action='list' to see available snapshots.`
454
+ }
455
+ ]
456
+ };
457
+ }
458
+ const current = await configGet();
459
+ if (current.ok && current.data !== void 0) {
460
+ saveSnapshot(current.data, "caddy_revert");
461
+ }
462
+ const res = await loadConfig(snap.config, "application/json");
463
+ if (!res.ok) return formatResult(res);
464
+ const when = new Date(snap.timestamp).toISOString();
465
+ return {
466
+ content: [
467
+ { type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).` }
468
+ ]
469
+ };
470
+ }
471
+ );
313
472
  server.tool(
314
473
  "caddy_config_by_id",
315
474
  "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 +506,29 @@ function registerConfigTools(server) {
347
506
 
348
507
  // src/tools/operational.ts
349
508
  import { z as z3 } from "zod";
509
+ function describeServer(raw) {
510
+ const listen = Array.isArray(raw.listen) ? raw.listen : [];
511
+ const routes = Array.isArray(raw.routes) ? raw.routes : [];
512
+ const hasExplicitTls = !!raw.tls_connection_policies;
513
+ const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
514
+ const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
515
+ const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
516
+ return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
517
+ }
518
+ function findAcmeEmail(policies) {
519
+ if (!Array.isArray(policies)) return void 0;
520
+ for (const rawPolicy of policies) {
521
+ if (!rawPolicy || typeof rawPolicy !== "object") continue;
522
+ const policy = rawPolicy;
523
+ if (!Array.isArray(policy.issuers)) continue;
524
+ for (const rawIssuer of policy.issuers) {
525
+ if (!rawIssuer || typeof rawIssuer !== "object") continue;
526
+ const issuer = rawIssuer;
527
+ if (typeof issuer.email === "string") return issuer.email;
528
+ }
529
+ }
530
+ return void 0;
531
+ }
350
532
  function registerOperationalTools(server) {
351
533
  server.tool(
352
534
  "caddy_status",
@@ -356,32 +538,20 @@ function registerOperationalTools(server) {
356
538
  async () => {
357
539
  const res = await configGet();
358
540
  if (!res.ok) return formatResult(res);
359
- const config = res.data || {};
360
- const httpApp = config?.apps?.http;
361
- const servers = httpApp?.servers || {};
541
+ const config = res.data ?? {};
542
+ const servers = config.apps?.http?.servers ?? {};
362
543
  const serverNames = Object.keys(servers);
363
544
  const lines = ["Caddy is running", ""];
364
545
  if (serverNames.length === 0) {
365
546
  lines.push("No HTTP servers configured");
366
547
  } else {
367
548
  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
- );
549
+ lines.push(`Server "${name}": ${describeServer(servers[name])}`);
377
550
  }
378
551
  }
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(`
552
+ const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
553
+ if (email) lines.push(`
383
554
  ACME email: ${email}`);
384
- }
385
555
  return { content: [{ type: "text", text: lines.join("\n") }] };
386
556
  }
387
557
  );
@@ -393,21 +563,12 @@ ACME email: ${email}`);
393
563
  async () => {
394
564
  const res = await configGet("apps/http/servers");
395
565
  if (!res.ok) return formatResult(res);
396
- const servers = res.data || {};
566
+ const servers = res.data ?? {};
397
567
  const names = Object.keys(servers);
398
568
  if (names.length === 0) {
399
569
  return { content: [{ type: "text", text: "No HTTP servers configured" }] };
400
570
  }
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
- }
571
+ const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
411
572
  return {
412
573
  content: [{ type: "text", text: `HTTP Servers:
413
574
  ${lines.join("\n")}` }]
@@ -678,7 +839,7 @@ function registerRouteTools(server) {
678
839
  );
679
840
  server.tool(
680
841
  "caddy_remove_route",
681
- "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
842
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
682
843
  {
683
844
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
684
845
  index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "0.3.1",
3
+ "version": "1.0.1",
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)",