@yawlabs/caddy-mcp 1.0.1 → 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 -5
- package/dist/index.js +266 -39
- package/dist/server.js +266 -39
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://github.com/YawLabs/caddy-mcp/stargazers)
|
|
6
|
-
[](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
|
|
7
6
|
|
|
8
7
|
**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
8
|
|
|
@@ -108,14 +107,14 @@ Use the same JSON block shown above in any of these.
|
|
|
108
107
|
|
|
109
108
|
### Route operations (4)
|
|
110
109
|
|
|
111
|
-
- **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.
|
|
112
111
|
- **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
|
|
113
112
|
- **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
|
|
114
113
|
- **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
|
|
115
114
|
|
|
116
115
|
### TLS & config conversion (2)
|
|
117
116
|
|
|
118
|
-
- **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.
|
|
119
118
|
- **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
|
|
120
119
|
|
|
121
120
|
### Server operations (6)
|
|
@@ -123,7 +122,7 @@ Use the same JSON block shown above in any of these.
|
|
|
123
122
|
- **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
|
|
124
123
|
- **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
|
|
125
124
|
- **caddy_upstreams** — Reverse proxy backend health.
|
|
126
|
-
- **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.
|
|
127
126
|
- **caddy_pki** — CA info and certificate chains (default CA: `local`).
|
|
128
127
|
- **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
|
|
129
128
|
|
|
@@ -145,6 +144,26 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
|
|
|
145
144
|
→ caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
|
|
146
145
|
```
|
|
147
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
|
+
|
|
148
167
|
### Preview a Caddyfile before applying it
|
|
149
168
|
|
|
150
169
|
```
|
|
@@ -214,7 +233,7 @@ npm install
|
|
|
214
233
|
npm run lint # Biome check
|
|
215
234
|
npm run lint:fix # Auto-fix
|
|
216
235
|
npm run build # tsup bundle
|
|
217
|
-
npm test # Vitest (
|
|
236
|
+
npm test # Vitest (150 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
|
|
218
237
|
npm run typecheck # tsc --noEmit
|
|
219
238
|
```
|
|
220
239
|
|
package/dist/index.js
CHANGED
|
@@ -97,7 +97,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
97
97
|
setEtag(path, etag);
|
|
98
98
|
}
|
|
99
99
|
if (isWrite && res.ok && isConfigPath) {
|
|
100
|
-
|
|
100
|
+
if (method === "PATCH" || method === "PUT") {
|
|
101
|
+
if (etag) {
|
|
102
|
+
setEtag(path, etag);
|
|
103
|
+
} else {
|
|
104
|
+
etagCache.delete(path);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
etagCache.delete(path);
|
|
108
|
+
}
|
|
101
109
|
}
|
|
102
110
|
if (!res.ok) {
|
|
103
111
|
if (res.status === 412) {
|
|
@@ -167,9 +175,17 @@ function configDelete(path) {
|
|
|
167
175
|
if (bad) return Promise.resolve(bad);
|
|
168
176
|
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
169
177
|
}
|
|
170
|
-
|
|
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
|
+
}
|
|
171
187
|
async function loadConfig(config, contentType) {
|
|
172
|
-
const res = await caddyRequest("POST", "/load", config, contentType,
|
|
188
|
+
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
|
|
173
189
|
if (res.ok) etagCache.clear();
|
|
174
190
|
return res;
|
|
175
191
|
}
|
|
@@ -183,24 +199,34 @@ function getUpstreams() {
|
|
|
183
199
|
return caddyRequest("GET", "/reverse_proxy/upstreams");
|
|
184
200
|
}
|
|
185
201
|
function getPki(ca = "local") {
|
|
202
|
+
const bad = rejectTraversal(ca);
|
|
203
|
+
if (bad) return Promise.resolve(bad);
|
|
186
204
|
return caddyRequest("GET", `/pki/ca/${ca}`);
|
|
187
205
|
}
|
|
188
206
|
function getPkiCertificates(ca = "local") {
|
|
207
|
+
const bad = rejectTraversal(ca);
|
|
208
|
+
if (bad) return Promise.resolve(bad);
|
|
189
209
|
return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
|
|
190
210
|
}
|
|
191
211
|
function configByIdGet(id, subpath = "") {
|
|
212
|
+
const badId = rejectTraversal(id);
|
|
213
|
+
if (badId) return Promise.resolve(badId);
|
|
192
214
|
const bad = rejectTraversal(subpath);
|
|
193
215
|
if (bad) return Promise.resolve(bad);
|
|
194
216
|
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
195
217
|
return caddyRequest("GET", path);
|
|
196
218
|
}
|
|
197
219
|
function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
220
|
+
const badId = rejectTraversal(id);
|
|
221
|
+
if (badId) return Promise.resolve(badId);
|
|
198
222
|
const bad = rejectTraversal(subpath);
|
|
199
223
|
if (bad) return Promise.resolve(bad);
|
|
200
224
|
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
201
225
|
return caddyRequest(method, path, value);
|
|
202
226
|
}
|
|
203
227
|
function configByIdDelete(id, subpath = "") {
|
|
228
|
+
const badId = rejectTraversal(id);
|
|
229
|
+
if (badId) return Promise.resolve(badId);
|
|
204
230
|
const bad = rejectTraversal(subpath);
|
|
205
231
|
if (bad) return Promise.resolve(bad);
|
|
206
232
|
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
@@ -351,6 +377,9 @@ function getSnapshot(index) {
|
|
|
351
377
|
}
|
|
352
378
|
|
|
353
379
|
// src/tools/config.ts
|
|
380
|
+
function isSnapshotableConfig(data) {
|
|
381
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
382
|
+
}
|
|
354
383
|
function registerConfigTools(server) {
|
|
355
384
|
server.tool(
|
|
356
385
|
"caddy_config_get",
|
|
@@ -393,7 +422,7 @@ function registerConfigTools(server) {
|
|
|
393
422
|
async ({ config, format }) => {
|
|
394
423
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
395
424
|
const current = await configGet();
|
|
396
|
-
if (current.ok && current.data
|
|
425
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
397
426
|
saveSnapshot(current.data, "caddy_load");
|
|
398
427
|
}
|
|
399
428
|
return formatResult(await loadConfig(config, contentType));
|
|
@@ -425,10 +454,15 @@ ${lines.join("\n")}` }] };
|
|
|
425
454
|
if (action === "save") {
|
|
426
455
|
const current2 = await configGet();
|
|
427
456
|
if (!current2.ok) return formatResult(current2);
|
|
428
|
-
if (current2.data
|
|
457
|
+
if (!isSnapshotableConfig(current2.data)) {
|
|
429
458
|
return {
|
|
430
459
|
isError: true,
|
|
431
|
-
content: [
|
|
460
|
+
content: [
|
|
461
|
+
{
|
|
462
|
+
type: "text",
|
|
463
|
+
text: "Error: cannot snapshot -- config response is empty or not a JSON object"
|
|
464
|
+
}
|
|
465
|
+
]
|
|
432
466
|
};
|
|
433
467
|
}
|
|
434
468
|
saveSnapshot(current2.data, "manual");
|
|
@@ -458,11 +492,11 @@ ${lines.join("\n")}` }] };
|
|
|
458
492
|
};
|
|
459
493
|
}
|
|
460
494
|
const current = await configGet();
|
|
461
|
-
if (current.ok && current.data !== void 0) {
|
|
462
|
-
saveSnapshot(current.data, "caddy_revert");
|
|
463
|
-
}
|
|
464
495
|
const res = await loadConfig(snap.config, "application/json");
|
|
465
496
|
if (!res.ok) return formatResult(res);
|
|
497
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
498
|
+
saveSnapshot(current.data, "caddy_revert");
|
|
499
|
+
}
|
|
466
500
|
const when = new Date(snap.timestamp).toISOString();
|
|
467
501
|
return {
|
|
468
502
|
content: [
|
|
@@ -517,19 +551,46 @@ function describeServer(raw) {
|
|
|
517
551
|
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
518
552
|
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
519
553
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
554
|
+
var METRICS_DEFAULT_MAX_LINES = 500;
|
|
555
|
+
function metricNameFromLine(line) {
|
|
556
|
+
const trimmed = line.trimStart();
|
|
557
|
+
if (trimmed === "") return void 0;
|
|
558
|
+
if (trimmed.startsWith("#")) {
|
|
559
|
+
const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
560
|
+
return m2 ? m2[1] : void 0;
|
|
561
|
+
}
|
|
562
|
+
const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
563
|
+
return m ? m[1] : void 0;
|
|
564
|
+
}
|
|
565
|
+
function applyMetricsControls(raw, filter, maxLines) {
|
|
566
|
+
const lines = raw.split("\n");
|
|
567
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
568
|
+
let filtered;
|
|
569
|
+
if (filter && filter.length > 0) {
|
|
570
|
+
filtered = lines.filter((line) => {
|
|
571
|
+
if (line.trim() === "# EOF") return true;
|
|
572
|
+
const name = metricNameFromLine(line);
|
|
573
|
+
return name?.includes(filter) ?? false;
|
|
574
|
+
});
|
|
575
|
+
} else {
|
|
576
|
+
filtered = lines;
|
|
531
577
|
}
|
|
532
|
-
return
|
|
578
|
+
if (filtered.length <= maxLines) return filtered.join("\n");
|
|
579
|
+
const dropped = filtered.length - maxLines;
|
|
580
|
+
const kept = filtered.slice(0, maxLines);
|
|
581
|
+
kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
|
|
582
|
+
return kept.join("\n");
|
|
583
|
+
}
|
|
584
|
+
function findAcmeEmail(policies) {
|
|
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;
|
|
533
594
|
}
|
|
534
595
|
function registerOperationalTools(server) {
|
|
535
596
|
server.tool(
|
|
@@ -599,10 +660,22 @@ ${lines.join("\n")}` }]
|
|
|
599
660
|
);
|
|
600
661
|
server.tool(
|
|
601
662
|
"caddy_metrics",
|
|
602
|
-
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
|
|
603
|
-
{
|
|
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.",
|
|
664
|
+
{
|
|
665
|
+
filter: z3.string().optional().describe(
|
|
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."
|
|
667
|
+
),
|
|
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.")
|
|
669
|
+
},
|
|
604
670
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
605
|
-
async () =>
|
|
671
|
+
async ({ filter, max_lines }) => {
|
|
672
|
+
const res = await getMetrics();
|
|
673
|
+
if (!res.ok) return formatResult(res);
|
|
674
|
+
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
675
|
+
const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
|
|
676
|
+
const text = applyMetricsControls(raw, filter, limit);
|
|
677
|
+
return { content: [{ type: "text", text: text || "OK" }] };
|
|
678
|
+
}
|
|
606
679
|
);
|
|
607
680
|
server.tool(
|
|
608
681
|
"caddy_stop",
|
|
@@ -633,7 +706,10 @@ function parseFrom(from) {
|
|
|
633
706
|
const slashIdx = cleaned.indexOf("/");
|
|
634
707
|
if (slashIdx > 0) {
|
|
635
708
|
match.host = [cleaned.substring(0, slashIdx)];
|
|
636
|
-
|
|
709
|
+
const path = cleaned.substring(slashIdx);
|
|
710
|
+
if (path !== "/") {
|
|
711
|
+
match.path = [path];
|
|
712
|
+
}
|
|
637
713
|
} else if (cleaned.startsWith("/")) {
|
|
638
714
|
match.path = [cleaned];
|
|
639
715
|
} else {
|
|
@@ -644,6 +720,21 @@ function parseFrom(from) {
|
|
|
644
720
|
function cleanUpstreamAddr(addr) {
|
|
645
721
|
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
646
722
|
}
|
|
723
|
+
function isParentMissing(res) {
|
|
724
|
+
if (res.ok) return false;
|
|
725
|
+
if (res.status === 404) return true;
|
|
726
|
+
return res.error?.includes("key does not exist") ?? false;
|
|
727
|
+
}
|
|
728
|
+
function isUnknownId(res) {
|
|
729
|
+
if (res.ok) return false;
|
|
730
|
+
if (res.status === 404) return true;
|
|
731
|
+
const body = (res.error ?? "").toLowerCase();
|
|
732
|
+
return body.includes("unknown object id") || body.includes("no id found");
|
|
733
|
+
}
|
|
734
|
+
function isRouteShape(obj) {
|
|
735
|
+
if (!obj || typeof obj !== "object") return false;
|
|
736
|
+
return Array.isArray(obj.handle);
|
|
737
|
+
}
|
|
647
738
|
function serverNotFoundError(srv) {
|
|
648
739
|
return {
|
|
649
740
|
isError: true,
|
|
@@ -658,14 +749,17 @@ function serverNotFoundError(srv) {
|
|
|
658
749
|
function registerRouteTools(server) {
|
|
659
750
|
server.tool(
|
|
660
751
|
"caddy_reverse_proxy",
|
|
661
|
-
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000'].",
|
|
752
|
+
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PUT under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
|
|
662
753
|
{
|
|
663
754
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
664
755
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
665
|
-
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
756
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
757
|
+
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
|
|
758
|
+
"Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
|
|
759
|
+
)
|
|
666
760
|
},
|
|
667
761
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
668
|
-
async ({ from, to, server: srv }) => {
|
|
762
|
+
async ({ from, to, server: srv, id }) => {
|
|
669
763
|
const match = parseFrom(from);
|
|
670
764
|
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
671
765
|
const route = {
|
|
@@ -678,11 +772,58 @@ function registerRouteTools(server) {
|
|
|
678
772
|
],
|
|
679
773
|
terminal: true
|
|
680
774
|
};
|
|
775
|
+
if (id) {
|
|
776
|
+
route["@id"] = id;
|
|
777
|
+
const existing = await configByIdGet(id);
|
|
778
|
+
if (existing.ok) {
|
|
779
|
+
if (!isRouteShape(existing.data)) {
|
|
780
|
+
return {
|
|
781
|
+
isError: true,
|
|
782
|
+
content: [
|
|
783
|
+
{
|
|
784
|
+
type: "text",
|
|
785
|
+
text: `Error: @id "${id}" is already in use by a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- pick a different id, or remove the existing object first with caddy_config_by_id { id: "${id}", action: "delete" }.`
|
|
786
|
+
}
|
|
787
|
+
]
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const putRes = await configByIdSet(id, route, "PUT");
|
|
791
|
+
if (putRes.ok) {
|
|
792
|
+
return {
|
|
793
|
+
content: [
|
|
794
|
+
{
|
|
795
|
+
type: "text",
|
|
796
|
+
text: `Route set @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
|
|
797
|
+
}
|
|
798
|
+
]
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return formatResult(putRes);
|
|
802
|
+
}
|
|
803
|
+
if (!isUnknownId(existing)) {
|
|
804
|
+
return formatResult(existing);
|
|
805
|
+
}
|
|
806
|
+
const postRes = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
807
|
+
if (postRes.ok) {
|
|
808
|
+
return {
|
|
809
|
+
content: [
|
|
810
|
+
{
|
|
811
|
+
type: "text",
|
|
812
|
+
text: `Route created @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
|
|
813
|
+
}
|
|
814
|
+
]
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (isParentMissing(postRes)) {
|
|
818
|
+
return serverNotFoundError(srv);
|
|
819
|
+
}
|
|
820
|
+
return formatResult(postRes);
|
|
821
|
+
}
|
|
681
822
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
682
823
|
if (res.ok) {
|
|
683
824
|
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
|
|
684
825
|
}
|
|
685
|
-
if (
|
|
826
|
+
if (isParentMissing(res)) {
|
|
686
827
|
return serverNotFoundError(srv);
|
|
687
828
|
}
|
|
688
829
|
return formatResult(res);
|
|
@@ -701,7 +842,7 @@ function registerRouteTools(server) {
|
|
|
701
842
|
async ({ match, handle, server: srv, terminal }) => {
|
|
702
843
|
const route = { match, handle, terminal };
|
|
703
844
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
704
|
-
if (
|
|
845
|
+
if (isParentMissing(res)) {
|
|
705
846
|
return serverNotFoundError(srv);
|
|
706
847
|
}
|
|
707
848
|
return formatResult(res);
|
|
@@ -914,9 +1055,9 @@ function buildTlsConfig(fields) {
|
|
|
914
1055
|
}
|
|
915
1056
|
};
|
|
916
1057
|
}
|
|
917
|
-
function bothErrors(label, patchRes,
|
|
1058
|
+
function bothErrors(label, patchRes, writeRes, writeLabel) {
|
|
918
1059
|
const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
|
|
919
|
-
const
|
|
1060
|
+
const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
|
|
920
1061
|
return {
|
|
921
1062
|
isError: true,
|
|
922
1063
|
content: [
|
|
@@ -924,11 +1065,97 @@ function bothErrors(label, patchRes, postRes) {
|
|
|
924
1065
|
type: "text",
|
|
925
1066
|
text: `Error: Failed to set ${label}.
|
|
926
1067
|
PATCH attempt: ${patchErr}
|
|
927
|
-
|
|
1068
|
+
${writeLabel} fallback: ${writeErr}`
|
|
928
1069
|
}
|
|
929
1070
|
]
|
|
930
1071
|
};
|
|
931
1072
|
}
|
|
1073
|
+
function isPlainObject(v) {
|
|
1074
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1075
|
+
}
|
|
1076
|
+
function deepClone(v) {
|
|
1077
|
+
return JSON.parse(JSON.stringify(v));
|
|
1078
|
+
}
|
|
1079
|
+
function mergeIssuerFields(existing, fields) {
|
|
1080
|
+
const merged = deepClone(existing);
|
|
1081
|
+
const automation = merged.automation;
|
|
1082
|
+
const issuer = automation.policies[0].issuers[0];
|
|
1083
|
+
if (fields.email !== void 0) issuer.email = fields.email;
|
|
1084
|
+
if (fields.ca !== void 0) issuer.ca = fields.ca;
|
|
1085
|
+
return merged;
|
|
1086
|
+
}
|
|
1087
|
+
function validateIssuerShape(tls) {
|
|
1088
|
+
const automation = tls.automation;
|
|
1089
|
+
if (!isPlainObject(automation)) {
|
|
1090
|
+
return "apps/tls.automation is missing or not an object";
|
|
1091
|
+
}
|
|
1092
|
+
const policies = automation.policies;
|
|
1093
|
+
if (!Array.isArray(policies) || policies.length === 0) {
|
|
1094
|
+
return "apps/tls.automation.policies is missing, not an array, or empty";
|
|
1095
|
+
}
|
|
1096
|
+
const policy0 = policies[0];
|
|
1097
|
+
if (!isPlainObject(policy0)) {
|
|
1098
|
+
return "apps/tls.automation.policies[0] is not an object";
|
|
1099
|
+
}
|
|
1100
|
+
const issuers = policy0.issuers;
|
|
1101
|
+
if (!Array.isArray(issuers) || issuers.length === 0) {
|
|
1102
|
+
return "apps/tls.automation.policies[0].issuers is missing, not an array, or empty";
|
|
1103
|
+
}
|
|
1104
|
+
const issuer0 = issuers[0];
|
|
1105
|
+
if (!isPlainObject(issuer0)) {
|
|
1106
|
+
return "apps/tls.automation.policies[0].issuers[0] is not an object";
|
|
1107
|
+
}
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
async function safeFallback(label, patchRes, fields) {
|
|
1111
|
+
const getRes = await configGet("apps/tls");
|
|
1112
|
+
const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
|
|
1113
|
+
if (absent) {
|
|
1114
|
+
const postRes = await configPost("apps/tls", buildTlsConfig(fields));
|
|
1115
|
+
if (postRes.ok) return { kind: "ok" };
|
|
1116
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
|
|
1117
|
+
}
|
|
1118
|
+
if (!getRes.ok) {
|
|
1119
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
|
|
1120
|
+
}
|
|
1121
|
+
if (!isPlainObject(getRes.data)) {
|
|
1122
|
+
return {
|
|
1123
|
+
kind: "tool-error",
|
|
1124
|
+
result: {
|
|
1125
|
+
isError: true,
|
|
1126
|
+
content: [
|
|
1127
|
+
{
|
|
1128
|
+
type: "text",
|
|
1129
|
+
text: `Error: Failed to set ${label}.
|
|
1130
|
+
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1131
|
+
Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
|
|
1132
|
+
}
|
|
1133
|
+
]
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
const shapeError = validateIssuerShape(getRes.data);
|
|
1138
|
+
if (shapeError) {
|
|
1139
|
+
return {
|
|
1140
|
+
kind: "tool-error",
|
|
1141
|
+
result: {
|
|
1142
|
+
isError: true,
|
|
1143
|
+
content: [
|
|
1144
|
+
{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: `Error: Failed to set ${label}.
|
|
1147
|
+
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1148
|
+
Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
|
|
1149
|
+
}
|
|
1150
|
+
]
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
const merged = mergeIssuerFields(getRes.data, fields);
|
|
1155
|
+
const putRes = await configPut("apps/tls", merged);
|
|
1156
|
+
if (putRes.ok) return { kind: "ok" };
|
|
1157
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
|
|
1158
|
+
}
|
|
932
1159
|
function registerTlsTools(server) {
|
|
933
1160
|
server.tool(
|
|
934
1161
|
"caddy_tls",
|
|
@@ -951,9 +1178,9 @@ function registerTlsTools(server) {
|
|
|
951
1178
|
};
|
|
952
1179
|
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
953
1180
|
if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
954
|
-
const
|
|
955
|
-
if (
|
|
956
|
-
return
|
|
1181
|
+
const outcome = await safeFallback("ACME email", patchRes, { email });
|
|
1182
|
+
if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
1183
|
+
return outcome.result;
|
|
957
1184
|
}
|
|
958
1185
|
if (action === "set_acme_ca") {
|
|
959
1186
|
if (!ca)
|
|
@@ -963,9 +1190,9 @@ function registerTlsTools(server) {
|
|
|
963
1190
|
};
|
|
964
1191
|
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
965
1192
|
if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
966
|
-
const
|
|
967
|
-
if (
|
|
968
|
-
return
|
|
1193
|
+
const outcome = await safeFallback("ACME CA", patchRes, { ca });
|
|
1194
|
+
if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
1195
|
+
return outcome.result;
|
|
969
1196
|
}
|
|
970
1197
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
971
1198
|
}
|
package/dist/server.js
CHANGED
|
@@ -95,7 +95,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
95
95
|
setEtag(path, etag);
|
|
96
96
|
}
|
|
97
97
|
if (isWrite && res.ok && isConfigPath) {
|
|
98
|
-
|
|
98
|
+
if (method === "PATCH" || method === "PUT") {
|
|
99
|
+
if (etag) {
|
|
100
|
+
setEtag(path, etag);
|
|
101
|
+
} else {
|
|
102
|
+
etagCache.delete(path);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
etagCache.delete(path);
|
|
106
|
+
}
|
|
99
107
|
}
|
|
100
108
|
if (!res.ok) {
|
|
101
109
|
if (res.status === 412) {
|
|
@@ -165,9 +173,17 @@ function configDelete(path) {
|
|
|
165
173
|
if (bad) return Promise.resolve(bad);
|
|
166
174
|
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
167
175
|
}
|
|
168
|
-
|
|
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
|
+
}
|
|
169
185
|
async function loadConfig(config, contentType) {
|
|
170
|
-
const res = await caddyRequest("POST", "/load", config, contentType,
|
|
186
|
+
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
|
|
171
187
|
if (res.ok) etagCache.clear();
|
|
172
188
|
return res;
|
|
173
189
|
}
|
|
@@ -181,24 +197,34 @@ function getUpstreams() {
|
|
|
181
197
|
return caddyRequest("GET", "/reverse_proxy/upstreams");
|
|
182
198
|
}
|
|
183
199
|
function getPki(ca = "local") {
|
|
200
|
+
const bad = rejectTraversal(ca);
|
|
201
|
+
if (bad) return Promise.resolve(bad);
|
|
184
202
|
return caddyRequest("GET", `/pki/ca/${ca}`);
|
|
185
203
|
}
|
|
186
204
|
function getPkiCertificates(ca = "local") {
|
|
205
|
+
const bad = rejectTraversal(ca);
|
|
206
|
+
if (bad) return Promise.resolve(bad);
|
|
187
207
|
return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
|
|
188
208
|
}
|
|
189
209
|
function configByIdGet(id, subpath = "") {
|
|
210
|
+
const badId = rejectTraversal(id);
|
|
211
|
+
if (badId) return Promise.resolve(badId);
|
|
190
212
|
const bad = rejectTraversal(subpath);
|
|
191
213
|
if (bad) return Promise.resolve(bad);
|
|
192
214
|
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
193
215
|
return caddyRequest("GET", path);
|
|
194
216
|
}
|
|
195
217
|
function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
218
|
+
const badId = rejectTraversal(id);
|
|
219
|
+
if (badId) return Promise.resolve(badId);
|
|
196
220
|
const bad = rejectTraversal(subpath);
|
|
197
221
|
if (bad) return Promise.resolve(bad);
|
|
198
222
|
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
199
223
|
return caddyRequest(method, path, value);
|
|
200
224
|
}
|
|
201
225
|
function configByIdDelete(id, subpath = "") {
|
|
226
|
+
const badId = rejectTraversal(id);
|
|
227
|
+
if (badId) return Promise.resolve(badId);
|
|
202
228
|
const bad = rejectTraversal(subpath);
|
|
203
229
|
if (bad) return Promise.resolve(bad);
|
|
204
230
|
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
@@ -349,6 +375,9 @@ function getSnapshot(index) {
|
|
|
349
375
|
}
|
|
350
376
|
|
|
351
377
|
// src/tools/config.ts
|
|
378
|
+
function isSnapshotableConfig(data) {
|
|
379
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
380
|
+
}
|
|
352
381
|
function registerConfigTools(server) {
|
|
353
382
|
server.tool(
|
|
354
383
|
"caddy_config_get",
|
|
@@ -391,7 +420,7 @@ function registerConfigTools(server) {
|
|
|
391
420
|
async ({ config, format }) => {
|
|
392
421
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
393
422
|
const current = await configGet();
|
|
394
|
-
if (current.ok && current.data
|
|
423
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
395
424
|
saveSnapshot(current.data, "caddy_load");
|
|
396
425
|
}
|
|
397
426
|
return formatResult(await loadConfig(config, contentType));
|
|
@@ -423,10 +452,15 @@ ${lines.join("\n")}` }] };
|
|
|
423
452
|
if (action === "save") {
|
|
424
453
|
const current2 = await configGet();
|
|
425
454
|
if (!current2.ok) return formatResult(current2);
|
|
426
|
-
if (current2.data
|
|
455
|
+
if (!isSnapshotableConfig(current2.data)) {
|
|
427
456
|
return {
|
|
428
457
|
isError: true,
|
|
429
|
-
content: [
|
|
458
|
+
content: [
|
|
459
|
+
{
|
|
460
|
+
type: "text",
|
|
461
|
+
text: "Error: cannot snapshot -- config response is empty or not a JSON object"
|
|
462
|
+
}
|
|
463
|
+
]
|
|
430
464
|
};
|
|
431
465
|
}
|
|
432
466
|
saveSnapshot(current2.data, "manual");
|
|
@@ -456,11 +490,11 @@ ${lines.join("\n")}` }] };
|
|
|
456
490
|
};
|
|
457
491
|
}
|
|
458
492
|
const current = await configGet();
|
|
459
|
-
if (current.ok && current.data !== void 0) {
|
|
460
|
-
saveSnapshot(current.data, "caddy_revert");
|
|
461
|
-
}
|
|
462
493
|
const res = await loadConfig(snap.config, "application/json");
|
|
463
494
|
if (!res.ok) return formatResult(res);
|
|
495
|
+
if (current.ok && isSnapshotableConfig(current.data)) {
|
|
496
|
+
saveSnapshot(current.data, "caddy_revert");
|
|
497
|
+
}
|
|
464
498
|
const when = new Date(snap.timestamp).toISOString();
|
|
465
499
|
return {
|
|
466
500
|
content: [
|
|
@@ -515,19 +549,46 @@ function describeServer(raw) {
|
|
|
515
549
|
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
516
550
|
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
517
551
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
552
|
+
var METRICS_DEFAULT_MAX_LINES = 500;
|
|
553
|
+
function metricNameFromLine(line) {
|
|
554
|
+
const trimmed = line.trimStart();
|
|
555
|
+
if (trimmed === "") return void 0;
|
|
556
|
+
if (trimmed.startsWith("#")) {
|
|
557
|
+
const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
558
|
+
return m2 ? m2[1] : void 0;
|
|
559
|
+
}
|
|
560
|
+
const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
561
|
+
return m ? m[1] : void 0;
|
|
562
|
+
}
|
|
563
|
+
function applyMetricsControls(raw, filter, maxLines) {
|
|
564
|
+
const lines = raw.split("\n");
|
|
565
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
566
|
+
let filtered;
|
|
567
|
+
if (filter && filter.length > 0) {
|
|
568
|
+
filtered = lines.filter((line) => {
|
|
569
|
+
if (line.trim() === "# EOF") return true;
|
|
570
|
+
const name = metricNameFromLine(line);
|
|
571
|
+
return name?.includes(filter) ?? false;
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
filtered = lines;
|
|
529
575
|
}
|
|
530
|
-
return
|
|
576
|
+
if (filtered.length <= maxLines) return filtered.join("\n");
|
|
577
|
+
const dropped = filtered.length - maxLines;
|
|
578
|
+
const kept = filtered.slice(0, maxLines);
|
|
579
|
+
kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
|
|
580
|
+
return kept.join("\n");
|
|
581
|
+
}
|
|
582
|
+
function findAcmeEmail(policies) {
|
|
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;
|
|
531
592
|
}
|
|
532
593
|
function registerOperationalTools(server) {
|
|
533
594
|
server.tool(
|
|
@@ -597,10 +658,22 @@ ${lines.join("\n")}` }]
|
|
|
597
658
|
);
|
|
598
659
|
server.tool(
|
|
599
660
|
"caddy_metrics",
|
|
600
|
-
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
|
|
601
|
-
{
|
|
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.",
|
|
662
|
+
{
|
|
663
|
+
filter: z3.string().optional().describe(
|
|
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."
|
|
665
|
+
),
|
|
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.")
|
|
667
|
+
},
|
|
602
668
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
603
|
-
async () =>
|
|
669
|
+
async ({ filter, max_lines }) => {
|
|
670
|
+
const res = await getMetrics();
|
|
671
|
+
if (!res.ok) return formatResult(res);
|
|
672
|
+
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
673
|
+
const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
|
|
674
|
+
const text = applyMetricsControls(raw, filter, limit);
|
|
675
|
+
return { content: [{ type: "text", text: text || "OK" }] };
|
|
676
|
+
}
|
|
604
677
|
);
|
|
605
678
|
server.tool(
|
|
606
679
|
"caddy_stop",
|
|
@@ -631,7 +704,10 @@ function parseFrom(from) {
|
|
|
631
704
|
const slashIdx = cleaned.indexOf("/");
|
|
632
705
|
if (slashIdx > 0) {
|
|
633
706
|
match.host = [cleaned.substring(0, slashIdx)];
|
|
634
|
-
|
|
707
|
+
const path = cleaned.substring(slashIdx);
|
|
708
|
+
if (path !== "/") {
|
|
709
|
+
match.path = [path];
|
|
710
|
+
}
|
|
635
711
|
} else if (cleaned.startsWith("/")) {
|
|
636
712
|
match.path = [cleaned];
|
|
637
713
|
} else {
|
|
@@ -642,6 +718,21 @@ function parseFrom(from) {
|
|
|
642
718
|
function cleanUpstreamAddr(addr) {
|
|
643
719
|
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
644
720
|
}
|
|
721
|
+
function isParentMissing(res) {
|
|
722
|
+
if (res.ok) return false;
|
|
723
|
+
if (res.status === 404) return true;
|
|
724
|
+
return res.error?.includes("key does not exist") ?? false;
|
|
725
|
+
}
|
|
726
|
+
function isUnknownId(res) {
|
|
727
|
+
if (res.ok) return false;
|
|
728
|
+
if (res.status === 404) return true;
|
|
729
|
+
const body = (res.error ?? "").toLowerCase();
|
|
730
|
+
return body.includes("unknown object id") || body.includes("no id found");
|
|
731
|
+
}
|
|
732
|
+
function isRouteShape(obj) {
|
|
733
|
+
if (!obj || typeof obj !== "object") return false;
|
|
734
|
+
return Array.isArray(obj.handle);
|
|
735
|
+
}
|
|
645
736
|
function serverNotFoundError(srv) {
|
|
646
737
|
return {
|
|
647
738
|
isError: true,
|
|
@@ -656,14 +747,17 @@ function serverNotFoundError(srv) {
|
|
|
656
747
|
function registerRouteTools(server) {
|
|
657
748
|
server.tool(
|
|
658
749
|
"caddy_reverse_proxy",
|
|
659
|
-
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000'].",
|
|
750
|
+
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PUT under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
|
|
660
751
|
{
|
|
661
752
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
662
753
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
663
|
-
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
754
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
755
|
+
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
|
|
756
|
+
"Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
|
|
757
|
+
)
|
|
664
758
|
},
|
|
665
759
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
666
|
-
async ({ from, to, server: srv }) => {
|
|
760
|
+
async ({ from, to, server: srv, id }) => {
|
|
667
761
|
const match = parseFrom(from);
|
|
668
762
|
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
669
763
|
const route = {
|
|
@@ -676,11 +770,58 @@ function registerRouteTools(server) {
|
|
|
676
770
|
],
|
|
677
771
|
terminal: true
|
|
678
772
|
};
|
|
773
|
+
if (id) {
|
|
774
|
+
route["@id"] = id;
|
|
775
|
+
const existing = await configByIdGet(id);
|
|
776
|
+
if (existing.ok) {
|
|
777
|
+
if (!isRouteShape(existing.data)) {
|
|
778
|
+
return {
|
|
779
|
+
isError: true,
|
|
780
|
+
content: [
|
|
781
|
+
{
|
|
782
|
+
type: "text",
|
|
783
|
+
text: `Error: @id "${id}" is already in use by a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- pick a different id, or remove the existing object first with caddy_config_by_id { id: "${id}", action: "delete" }.`
|
|
784
|
+
}
|
|
785
|
+
]
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
const putRes = await configByIdSet(id, route, "PUT");
|
|
789
|
+
if (putRes.ok) {
|
|
790
|
+
return {
|
|
791
|
+
content: [
|
|
792
|
+
{
|
|
793
|
+
type: "text",
|
|
794
|
+
text: `Route set @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
|
|
795
|
+
}
|
|
796
|
+
]
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
return formatResult(putRes);
|
|
800
|
+
}
|
|
801
|
+
if (!isUnknownId(existing)) {
|
|
802
|
+
return formatResult(existing);
|
|
803
|
+
}
|
|
804
|
+
const postRes = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
805
|
+
if (postRes.ok) {
|
|
806
|
+
return {
|
|
807
|
+
content: [
|
|
808
|
+
{
|
|
809
|
+
type: "text",
|
|
810
|
+
text: `Route created @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
|
|
811
|
+
}
|
|
812
|
+
]
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (isParentMissing(postRes)) {
|
|
816
|
+
return serverNotFoundError(srv);
|
|
817
|
+
}
|
|
818
|
+
return formatResult(postRes);
|
|
819
|
+
}
|
|
679
820
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
680
821
|
if (res.ok) {
|
|
681
822
|
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
|
|
682
823
|
}
|
|
683
|
-
if (
|
|
824
|
+
if (isParentMissing(res)) {
|
|
684
825
|
return serverNotFoundError(srv);
|
|
685
826
|
}
|
|
686
827
|
return formatResult(res);
|
|
@@ -699,7 +840,7 @@ function registerRouteTools(server) {
|
|
|
699
840
|
async ({ match, handle, server: srv, terminal }) => {
|
|
700
841
|
const route = { match, handle, terminal };
|
|
701
842
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
702
|
-
if (
|
|
843
|
+
if (isParentMissing(res)) {
|
|
703
844
|
return serverNotFoundError(srv);
|
|
704
845
|
}
|
|
705
846
|
return formatResult(res);
|
|
@@ -912,9 +1053,9 @@ function buildTlsConfig(fields) {
|
|
|
912
1053
|
}
|
|
913
1054
|
};
|
|
914
1055
|
}
|
|
915
|
-
function bothErrors(label, patchRes,
|
|
1056
|
+
function bothErrors(label, patchRes, writeRes, writeLabel) {
|
|
916
1057
|
const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
|
|
917
|
-
const
|
|
1058
|
+
const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
|
|
918
1059
|
return {
|
|
919
1060
|
isError: true,
|
|
920
1061
|
content: [
|
|
@@ -922,11 +1063,97 @@ function bothErrors(label, patchRes, postRes) {
|
|
|
922
1063
|
type: "text",
|
|
923
1064
|
text: `Error: Failed to set ${label}.
|
|
924
1065
|
PATCH attempt: ${patchErr}
|
|
925
|
-
|
|
1066
|
+
${writeLabel} fallback: ${writeErr}`
|
|
926
1067
|
}
|
|
927
1068
|
]
|
|
928
1069
|
};
|
|
929
1070
|
}
|
|
1071
|
+
function isPlainObject(v) {
|
|
1072
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1073
|
+
}
|
|
1074
|
+
function deepClone(v) {
|
|
1075
|
+
return JSON.parse(JSON.stringify(v));
|
|
1076
|
+
}
|
|
1077
|
+
function mergeIssuerFields(existing, fields) {
|
|
1078
|
+
const merged = deepClone(existing);
|
|
1079
|
+
const automation = merged.automation;
|
|
1080
|
+
const issuer = automation.policies[0].issuers[0];
|
|
1081
|
+
if (fields.email !== void 0) issuer.email = fields.email;
|
|
1082
|
+
if (fields.ca !== void 0) issuer.ca = fields.ca;
|
|
1083
|
+
return merged;
|
|
1084
|
+
}
|
|
1085
|
+
function validateIssuerShape(tls) {
|
|
1086
|
+
const automation = tls.automation;
|
|
1087
|
+
if (!isPlainObject(automation)) {
|
|
1088
|
+
return "apps/tls.automation is missing or not an object";
|
|
1089
|
+
}
|
|
1090
|
+
const policies = automation.policies;
|
|
1091
|
+
if (!Array.isArray(policies) || policies.length === 0) {
|
|
1092
|
+
return "apps/tls.automation.policies is missing, not an array, or empty";
|
|
1093
|
+
}
|
|
1094
|
+
const policy0 = policies[0];
|
|
1095
|
+
if (!isPlainObject(policy0)) {
|
|
1096
|
+
return "apps/tls.automation.policies[0] is not an object";
|
|
1097
|
+
}
|
|
1098
|
+
const issuers = policy0.issuers;
|
|
1099
|
+
if (!Array.isArray(issuers) || issuers.length === 0) {
|
|
1100
|
+
return "apps/tls.automation.policies[0].issuers is missing, not an array, or empty";
|
|
1101
|
+
}
|
|
1102
|
+
const issuer0 = issuers[0];
|
|
1103
|
+
if (!isPlainObject(issuer0)) {
|
|
1104
|
+
return "apps/tls.automation.policies[0].issuers[0] is not an object";
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
async function safeFallback(label, patchRes, fields) {
|
|
1109
|
+
const getRes = await configGet("apps/tls");
|
|
1110
|
+
const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
|
|
1111
|
+
if (absent) {
|
|
1112
|
+
const postRes = await configPost("apps/tls", buildTlsConfig(fields));
|
|
1113
|
+
if (postRes.ok) return { kind: "ok" };
|
|
1114
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
|
|
1115
|
+
}
|
|
1116
|
+
if (!getRes.ok) {
|
|
1117
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
|
|
1118
|
+
}
|
|
1119
|
+
if (!isPlainObject(getRes.data)) {
|
|
1120
|
+
return {
|
|
1121
|
+
kind: "tool-error",
|
|
1122
|
+
result: {
|
|
1123
|
+
isError: true,
|
|
1124
|
+
content: [
|
|
1125
|
+
{
|
|
1126
|
+
type: "text",
|
|
1127
|
+
text: `Error: Failed to set ${label}.
|
|
1128
|
+
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1129
|
+
Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
|
|
1130
|
+
}
|
|
1131
|
+
]
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
const shapeError = validateIssuerShape(getRes.data);
|
|
1136
|
+
if (shapeError) {
|
|
1137
|
+
return {
|
|
1138
|
+
kind: "tool-error",
|
|
1139
|
+
result: {
|
|
1140
|
+
isError: true,
|
|
1141
|
+
content: [
|
|
1142
|
+
{
|
|
1143
|
+
type: "text",
|
|
1144
|
+
text: `Error: Failed to set ${label}.
|
|
1145
|
+
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1146
|
+
Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
|
|
1147
|
+
}
|
|
1148
|
+
]
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const merged = mergeIssuerFields(getRes.data, fields);
|
|
1153
|
+
const putRes = await configPut("apps/tls", merged);
|
|
1154
|
+
if (putRes.ok) return { kind: "ok" };
|
|
1155
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
|
|
1156
|
+
}
|
|
930
1157
|
function registerTlsTools(server) {
|
|
931
1158
|
server.tool(
|
|
932
1159
|
"caddy_tls",
|
|
@@ -949,9 +1176,9 @@ function registerTlsTools(server) {
|
|
|
949
1176
|
};
|
|
950
1177
|
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
951
1178
|
if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
952
|
-
const
|
|
953
|
-
if (
|
|
954
|
-
return
|
|
1179
|
+
const outcome = await safeFallback("ACME email", patchRes, { email });
|
|
1180
|
+
if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
1181
|
+
return outcome.result;
|
|
955
1182
|
}
|
|
956
1183
|
if (action === "set_acme_ca") {
|
|
957
1184
|
if (!ca)
|
|
@@ -961,9 +1188,9 @@ function registerTlsTools(server) {
|
|
|
961
1188
|
};
|
|
962
1189
|
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
963
1190
|
if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
964
|
-
const
|
|
965
|
-
if (
|
|
966
|
-
return
|
|
1191
|
+
const outcome = await safeFallback("ACME CA", patchRes, { ca });
|
|
1192
|
+
if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
1193
|
+
return outcome.result;
|
|
967
1194
|
}
|
|
968
1195
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
969
1196
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "1.0
|
|
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)",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
},
|
|
41
41
|
"overrides": {
|
|
42
42
|
"hono": "^4.12.14",
|
|
43
|
-
"@hono/node-server": "^1.19.13"
|
|
43
|
+
"@hono/node-server": "^1.19.13",
|
|
44
|
+
"postcss": "^8.5.10",
|
|
45
|
+
"ip-address": "^10.1.1"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@biomejs/biome": "^2.4.11",
|