@yawlabs/caddy-mcp 1.1.0 → 1.2.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 +24 -4
- package/dist/index.js +35 -21
- package/dist/server.js +35 -21
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -107,14 +107,14 @@ Use the same JSON block shown above in any of these.
|
|
|
107
107
|
|
|
108
108
|
### Route operations (4)
|
|
109
109
|
|
|
110
|
-
- **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`.
|
|
110
|
+
- **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`. Pass an optional `id` for idempotent writes — repeat calls replace the route in place instead of duplicating.
|
|
111
111
|
- **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
|
|
112
112
|
- **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
|
|
113
113
|
- **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
|
|
114
114
|
|
|
115
115
|
### TLS & config conversion (2)
|
|
116
116
|
|
|
117
|
-
- **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL.
|
|
117
|
+
- **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. PATCH first; on a fresh install, POSTs a minimal config. On an existing config it deep-merges into the issuer path and PUTs the result back, preserving siblings (custom certs, `on_demand`, additional policies). Refuses with a shape-specific error if the existing structure is unexpected — never clobbers.
|
|
118
118
|
- **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
|
|
119
119
|
|
|
120
120
|
### Server operations (6)
|
|
@@ -122,7 +122,7 @@ Use the same JSON block shown above in any of these.
|
|
|
122
122
|
- **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
|
|
123
123
|
- **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
|
|
124
124
|
- **caddy_upstreams** — Reverse proxy backend health.
|
|
125
|
-
- **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes).
|
|
125
|
+
- **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes). Optional `filter` (substring match on metric name, keeps `# HELP` / `# TYPE` lines for retained metrics) and `max_lines` (default 500) keep responses compact on busy servers.
|
|
126
126
|
- **caddy_pki** — CA info and certificate chains (default CA: `local`).
|
|
127
127
|
- **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
|
|
128
128
|
|
|
@@ -144,6 +144,26 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
|
|
|
144
144
|
→ caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
### Idempotent reverse proxy (safe to re-run from automation)
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
> "Make sure api.example.com points at localhost:3000, with a stable id"
|
|
151
|
+
→ caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
|
|
152
|
+
# First call creates the route under @id="api-prod".
|
|
153
|
+
# Subsequent calls with the same id REPLACE in place — no duplicate routes.
|
|
154
|
+
# Refuses with a clear error if "api-prod" is already in use by a non-route
|
|
155
|
+
# config object (TLS issuer, server, etc.) — @ids are config-global in Caddy.
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Filter Prometheus metrics
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
> "Just the HTTP request metrics, please"
|
|
162
|
+
→ caddy_metrics({ filter: "http_requests" })
|
|
163
|
+
# Keeps sample lines whose metric name contains "http_requests",
|
|
164
|
+
# plus their `# HELP` / `# TYPE` lines. Drops the rest.
|
|
165
|
+
```
|
|
166
|
+
|
|
147
167
|
### Preview a Caddyfile before applying it
|
|
148
168
|
|
|
149
169
|
```
|
|
@@ -213,7 +233,7 @@ npm install
|
|
|
213
233
|
npm run lint # Biome check
|
|
214
234
|
npm run lint:fix # Auto-fix
|
|
215
235
|
npm run build # tsup bundle
|
|
216
|
-
npm test # Vitest (
|
|
236
|
+
npm test # Vitest (150 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
|
|
217
237
|
npm run typecheck # tsc --noEmit
|
|
218
238
|
```
|
|
219
239
|
|
package/dist/index.js
CHANGED
|
@@ -175,9 +175,17 @@ function configDelete(path) {
|
|
|
175
175
|
if (bad) return Promise.resolve(bad);
|
|
176
176
|
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
177
177
|
}
|
|
178
|
-
|
|
178
|
+
function getLoadTimeout() {
|
|
179
|
+
const raw = process.env.CADDY_LOAD_TIMEOUT;
|
|
180
|
+
if (raw === void 0) return 6e4;
|
|
181
|
+
const n = Number(raw);
|
|
182
|
+
if (!Number.isFinite(n)) return 6e4;
|
|
183
|
+
const floored = Math.floor(n);
|
|
184
|
+
if (floored < 1) return 6e4;
|
|
185
|
+
return floored;
|
|
186
|
+
}
|
|
179
187
|
async function loadConfig(config, contentType) {
|
|
180
|
-
const res = await caddyRequest("POST", "/load", config, contentType,
|
|
188
|
+
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
|
|
181
189
|
if (res.ok) etagCache.clear();
|
|
182
190
|
return res;
|
|
183
191
|
}
|
|
@@ -369,6 +377,9 @@ function getSnapshot(index) {
|
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
// src/tools/config.ts
|
|
380
|
+
function isSnapshotableConfig(data) {
|
|
381
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
382
|
+
}
|
|
372
383
|
function registerConfigTools(server) {
|
|
373
384
|
server.tool(
|
|
374
385
|
"caddy_config_get",
|
|
@@ -411,7 +422,7 @@ function registerConfigTools(server) {
|
|
|
411
422
|
async ({ config, format }) => {
|
|
412
423
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
413
424
|
const current = await configGet();
|
|
414
|
-
if (current.ok && current.data
|
|
425
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
415
426
|
saveSnapshot(current.data, "caddy_load");
|
|
416
427
|
}
|
|
417
428
|
return formatResult(await loadConfig(config, contentType));
|
|
@@ -443,10 +454,15 @@ ${lines.join("\n")}` }] };
|
|
|
443
454
|
if (action === "save") {
|
|
444
455
|
const current2 = await configGet();
|
|
445
456
|
if (!current2.ok) return formatResult(current2);
|
|
446
|
-
if (current2.data
|
|
457
|
+
if (!isSnapshotableConfig(current2.data)) {
|
|
447
458
|
return {
|
|
448
459
|
isError: true,
|
|
449
|
-
content: [
|
|
460
|
+
content: [
|
|
461
|
+
{
|
|
462
|
+
type: "text",
|
|
463
|
+
text: "Error: cannot snapshot -- config response is empty or not a JSON object"
|
|
464
|
+
}
|
|
465
|
+
]
|
|
450
466
|
};
|
|
451
467
|
}
|
|
452
468
|
saveSnapshot(current2.data, "manual");
|
|
@@ -476,11 +492,11 @@ ${lines.join("\n")}` }] };
|
|
|
476
492
|
};
|
|
477
493
|
}
|
|
478
494
|
const current = await configGet();
|
|
479
|
-
if (current.ok && current.data !== void 0) {
|
|
480
|
-
saveSnapshot(current.data, "caddy_revert");
|
|
481
|
-
}
|
|
482
495
|
const res = await loadConfig(snap.config, "application/json");
|
|
483
496
|
if (!res.ok) return formatResult(res);
|
|
497
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
498
|
+
saveSnapshot(current.data, "caddy_revert");
|
|
499
|
+
}
|
|
484
500
|
const when = new Date(snap.timestamp).toISOString();
|
|
485
501
|
return {
|
|
486
502
|
content: [
|
|
@@ -552,6 +568,7 @@ function applyMetricsControls(raw, filter, maxLines) {
|
|
|
552
568
|
let filtered;
|
|
553
569
|
if (filter && filter.length > 0) {
|
|
554
570
|
filtered = lines.filter((line) => {
|
|
571
|
+
if (line.trim() === "# EOF") return true;
|
|
555
572
|
const name = metricNameFromLine(line);
|
|
556
573
|
return name?.includes(filter) ?? false;
|
|
557
574
|
});
|
|
@@ -565,18 +582,15 @@ function applyMetricsControls(raw, filter, maxLines) {
|
|
|
565
582
|
return kept.join("\n");
|
|
566
583
|
}
|
|
567
584
|
function findAcmeEmail(policies) {
|
|
568
|
-
if (!Array.isArray(policies)) return void 0;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
return void 0;
|
|
585
|
+
if (!Array.isArray(policies) || policies.length === 0) return void 0;
|
|
586
|
+
const rawPolicy = policies[0];
|
|
587
|
+
if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
|
|
588
|
+
const policy = rawPolicy;
|
|
589
|
+
if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
|
|
590
|
+
const rawIssuer = policy.issuers[0];
|
|
591
|
+
if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
|
|
592
|
+
const issuer = rawIssuer;
|
|
593
|
+
return typeof issuer.email === "string" ? issuer.email : void 0;
|
|
580
594
|
}
|
|
581
595
|
function registerOperationalTools(server) {
|
|
582
596
|
server.tool(
|
|
@@ -649,7 +663,7 @@ ${lines.join("\n")}` }]
|
|
|
649
663
|
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
650
664
|
{
|
|
651
665
|
filter: z3.string().optional().describe(
|
|
652
|
-
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering."
|
|
666
|
+
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
653
667
|
),
|
|
654
668
|
max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
|
|
655
669
|
},
|
package/dist/server.js
CHANGED
|
@@ -173,9 +173,17 @@ function configDelete(path) {
|
|
|
173
173
|
if (bad) return Promise.resolve(bad);
|
|
174
174
|
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
175
175
|
}
|
|
176
|
-
|
|
176
|
+
function getLoadTimeout() {
|
|
177
|
+
const raw = process.env.CADDY_LOAD_TIMEOUT;
|
|
178
|
+
if (raw === void 0) return 6e4;
|
|
179
|
+
const n = Number(raw);
|
|
180
|
+
if (!Number.isFinite(n)) return 6e4;
|
|
181
|
+
const floored = Math.floor(n);
|
|
182
|
+
if (floored < 1) return 6e4;
|
|
183
|
+
return floored;
|
|
184
|
+
}
|
|
177
185
|
async function loadConfig(config, contentType) {
|
|
178
|
-
const res = await caddyRequest("POST", "/load", config, contentType,
|
|
186
|
+
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
|
|
179
187
|
if (res.ok) etagCache.clear();
|
|
180
188
|
return res;
|
|
181
189
|
}
|
|
@@ -367,6 +375,9 @@ function getSnapshot(index) {
|
|
|
367
375
|
}
|
|
368
376
|
|
|
369
377
|
// src/tools/config.ts
|
|
378
|
+
function isSnapshotableConfig(data) {
|
|
379
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
380
|
+
}
|
|
370
381
|
function registerConfigTools(server) {
|
|
371
382
|
server.tool(
|
|
372
383
|
"caddy_config_get",
|
|
@@ -409,7 +420,7 @@ function registerConfigTools(server) {
|
|
|
409
420
|
async ({ config, format }) => {
|
|
410
421
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
411
422
|
const current = await configGet();
|
|
412
|
-
if (current.ok && current.data
|
|
423
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
413
424
|
saveSnapshot(current.data, "caddy_load");
|
|
414
425
|
}
|
|
415
426
|
return formatResult(await loadConfig(config, contentType));
|
|
@@ -441,10 +452,15 @@ ${lines.join("\n")}` }] };
|
|
|
441
452
|
if (action === "save") {
|
|
442
453
|
const current2 = await configGet();
|
|
443
454
|
if (!current2.ok) return formatResult(current2);
|
|
444
|
-
if (current2.data
|
|
455
|
+
if (!isSnapshotableConfig(current2.data)) {
|
|
445
456
|
return {
|
|
446
457
|
isError: true,
|
|
447
|
-
content: [
|
|
458
|
+
content: [
|
|
459
|
+
{
|
|
460
|
+
type: "text",
|
|
461
|
+
text: "Error: cannot snapshot -- config response is empty or not a JSON object"
|
|
462
|
+
}
|
|
463
|
+
]
|
|
448
464
|
};
|
|
449
465
|
}
|
|
450
466
|
saveSnapshot(current2.data, "manual");
|
|
@@ -474,11 +490,11 @@ ${lines.join("\n")}` }] };
|
|
|
474
490
|
};
|
|
475
491
|
}
|
|
476
492
|
const current = await configGet();
|
|
477
|
-
if (current.ok && current.data !== void 0) {
|
|
478
|
-
saveSnapshot(current.data, "caddy_revert");
|
|
479
|
-
}
|
|
480
493
|
const res = await loadConfig(snap.config, "application/json");
|
|
481
494
|
if (!res.ok) return formatResult(res);
|
|
495
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
496
|
+
saveSnapshot(current.data, "caddy_revert");
|
|
497
|
+
}
|
|
482
498
|
const when = new Date(snap.timestamp).toISOString();
|
|
483
499
|
return {
|
|
484
500
|
content: [
|
|
@@ -550,6 +566,7 @@ function applyMetricsControls(raw, filter, maxLines) {
|
|
|
550
566
|
let filtered;
|
|
551
567
|
if (filter && filter.length > 0) {
|
|
552
568
|
filtered = lines.filter((line) => {
|
|
569
|
+
if (line.trim() === "# EOF") return true;
|
|
553
570
|
const name = metricNameFromLine(line);
|
|
554
571
|
return name?.includes(filter) ?? false;
|
|
555
572
|
});
|
|
@@ -563,18 +580,15 @@ function applyMetricsControls(raw, filter, maxLines) {
|
|
|
563
580
|
return kept.join("\n");
|
|
564
581
|
}
|
|
565
582
|
function findAcmeEmail(policies) {
|
|
566
|
-
if (!Array.isArray(policies)) return void 0;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
return void 0;
|
|
583
|
+
if (!Array.isArray(policies) || policies.length === 0) return void 0;
|
|
584
|
+
const rawPolicy = policies[0];
|
|
585
|
+
if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
|
|
586
|
+
const policy = rawPolicy;
|
|
587
|
+
if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
|
|
588
|
+
const rawIssuer = policy.issuers[0];
|
|
589
|
+
if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
|
|
590
|
+
const issuer = rawIssuer;
|
|
591
|
+
return typeof issuer.email === "string" ? issuer.email : void 0;
|
|
578
592
|
}
|
|
579
593
|
function registerOperationalTools(server) {
|
|
580
594
|
server.tool(
|
|
@@ -647,7 +661,7 @@ ${lines.join("\n")}` }]
|
|
|
647
661
|
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
648
662
|
{
|
|
649
663
|
filter: z3.string().optional().describe(
|
|
650
|
-
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering."
|
|
664
|
+
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
651
665
|
),
|
|
652
666
|
max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
|
|
653
667
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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)",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"overrides": {
|
|
42
42
|
"hono": "^4.12.14",
|
|
43
43
|
"@hono/node-server": "^1.19.13",
|
|
44
|
-
"postcss": "^8.5.10"
|
|
44
|
+
"postcss": "^8.5.10",
|
|
45
|
+
"ip-address": "^10.1.1"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@biomejs/biome": "^2.4.11",
|