@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 +6 -4
- package/dist/index.js +195 -34
- package/dist/server.js +195 -34
- package/package.json +1 -1
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
|
|
|
@@ -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,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
|
|
251
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
382
|
-
if (
|
|
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.
|
|
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
|
|
249
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
380
|
-
if (
|
|
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.
|
|
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)"),
|