@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 +7 -5
- package/dist/index.js +162 -33
- package/dist/server.js +162 -33
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/YawLabs/caddy-mcp/stargazers)
|
|
6
6
|
[](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [](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.**
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
251
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
382
|
-
if (
|
|
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
|
|
249
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
380
|
-
if (
|
|
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
|
+
"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": ">=
|
|
53
|
+
"node": ">=20"
|
|
50
54
|
},
|
|
51
55
|
"keywords": [
|
|
52
56
|
"mcp",
|