@yawlabs/caddy-mcp 2.5.4 → 2.5.6
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 +23 -14
- package/dist/api.d.ts +54 -0
- package/dist/index.js +197 -48
- package/dist/server.js +197 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @yawlabs/caddy-mcp
|
|
2
2
|
|
|
3
|
+
[](https://yaw.sh/mcp/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&description=Manage%20Caddy%20web%20servers%20-%20config%2C%20routes%2C%20TLS%2C%20PKI&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fcaddy-mcp)
|
|
4
|
+
|
|
5
|
+
One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
|
|
6
|
+
|
|
3
7
|
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
8
|
[](https://opensource.org/licenses/MIT)
|
|
5
9
|
[](https://github.com/YawLabs/caddy-mcp/stargazers)
|
|
@@ -8,17 +12,13 @@
|
|
|
8
12
|
|
|
9
13
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
10
14
|
|
|
11
|
-
[](https://yaw.sh/mcp/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&description=Manage%20Caddy%20web%20servers%20-%20config%2C%20routes%2C%20TLS%2C%20PKI&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fcaddy-mcp)
|
|
12
|
-
|
|
13
|
-
One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
|
|
14
|
-
|
|
15
15
|
## Why this one?
|
|
16
16
|
|
|
17
17
|
Other Caddy MCP servers wrap half the admin API and silently swallow errors. This one doesn't.
|
|
18
18
|
|
|
19
19
|
- **Complete admin API coverage** — every endpoint in [Caddy's admin API reference](https://caddyserver.com/docs/api): `/load`, `/config/*`, `/id/*`, `/stop`, `/adapt`, `/pki/ca/*`, `/reverse_proxy/upstreams`, `/metrics`. No placeholder tools that 404. Caddy's Go runtime debug endpoints (`/debug/pprof/*`, `/debug/vars`) are deliberately not wrapped — for leak trends use `caddy_metrics` with `filter: "go_goroutines"` or `"go_memstats"`, which come from the same admin registry; for stack dumps, CPU profiles and traces, curl the admin endpoint directly ([Caddy profiling docs](https://caddyserver.com/docs/profiling)).
|
|
20
20
|
- **Safe concurrent writes** — uses ETags (`If-Match`) so your changes never silently overwrite someone else's. Surfaces `HTTP 412 Precondition Failed` as a clear message, not a cryptic error.
|
|
21
|
-
- **Safe-by-default mutations** — `caddy_config_set` defaults to idempotent `overwrite` (PATCH), not `append` (POST). Calling twice doesn't duplicate your route.
|
|
21
|
+
- **Safe-by-default mutations** — `caddy_config_set` defaults to idempotent `overwrite` (PATCH), not `append` (POST). Calling twice doesn't duplicate your route. A write to the config root — which replaces the *entire* config, admin endpoint included — is refused without `confirm=true` and snapshotted first, like `caddy_load`.
|
|
22
22
|
- **Defensive parsing** — `caddy_list_routes` never crashes on malformed config, even if routes are null, handlers are strings, or matchers are non-arrays. Regression-tested.
|
|
23
23
|
- **No leaked credentials in errors** — if `CADDY_ADMIN_URL` contains a token in the path/query, the connect-failed message shows only the origin.
|
|
24
24
|
- **Fallback error surfacing** — when a TLS write PATCH fails and the PUT fallback also fails, both error bodies are returned so you know what actually went wrong.
|
|
@@ -121,11 +121,11 @@ Use the same JSON block shown above in any of these.
|
|
|
121
121
|
### Config management (6)
|
|
122
122
|
|
|
123
123
|
- **caddy_config_get** — Read config at any JSON path (or the full config).
|
|
124
|
-
- **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent; the key must exist), `append` (POST: appends to an array, but replaces an existing non-array key and cannot create missing parents), `insert` (PUT: inserts at an array position, or strictly creates a key along with any missing parents and fails with 409 if it exists — the way to create a server or app, even on an instance with no config). `append` at a path ending in `/...` (e.g. `apps/http/servers/srv0/routes/...`) with an **array** value appends every element in one request — all or nothing, one reload; without the `/...`, an array value is added as a single element and Caddy rejects the load for typed arrays like `routes` or `listen`.
|
|
125
|
-
- **caddy_config_delete** — Delete config at a path. Requires `confirm=true` (deleting a parent path also removes every descendant).
|
|
126
|
-
- **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths. The `delete` action requires `confirm=true`.
|
|
124
|
+
- **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent; the key must exist, and the whole subtree at the path is replaced), `append` (POST: appends to an array, but replaces an existing non-array key and cannot create missing parents), `insert` (PUT: inserts at an array position, or strictly creates a key along with any missing parents and fails with 409 if it exists — the way to create a server or app, even on an instance with no config). `append` at a path ending in `/...` (e.g. `apps/http/servers/srv0/routes/...`) with an **array** value appends every element in one request — all or nothing, one reload; without the `/...`, an array value is added as a single element and Caddy rejects the load for typed arrays like `routes` or `listen`. A path that addresses the config **root** (`''`, `'/'`, `'config'`, `'/config/'`, a slash-only variant, or any of those followed by a lone `...` segment) addresses the **entire** config and requires `confirm=true`: `overwrite` and `append` there replace the whole configuration with `value`, exactly as `caddy_load` would, including the `admin` block unless `value` carries one — after which Caddy re-binds its admin endpoint to the new config's `admin.listen`, to its default address when it sets none, or to no address at all when it sets `admin.disabled`, and a `CADDY_ADMIN_URL` pointing anywhere else stops working. A root write auto-snapshots the prior config first (when it can be read) so `caddy_revert` can restore it; no other path is snapshotted. `insert` at the root only succeeds after a root `caddy_config_delete` (409 otherwise), and `overwrite` is the reverse: after a root delete it answers 404 until `append`, `insert` or `caddy_load` re-creates the config.
|
|
125
|
+
- **caddy_config_delete** — Delete config at a path. Requires `confirm=true` (deleting a parent path also removes every descendant). A path that addresses the config root (`''`, `'/'`, `'config'`, `'/config/'`, a slash-only variant, or any of those followed by a lone `...` segment) addresses the **entire** config: it unloads every app and server plus the `admin` block, after which Caddy re-binds its admin endpoint to its default address (`localhost:2019`, or `$CADDY_ADMIN` in Caddy's environment) — if `CADDY_ADMIN_URL` points anywhere else, neither caddy-mcp nor `caddy_revert` can reach Caddy afterwards. A root delete auto-snapshots the prior config first (when it can be read), so `caddy_revert` can restore it while Caddy is still reachable; no other path is snapshotted. To replace the config rather than unload it, use `caddy_load`.
|
|
126
|
+
- **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths. The `delete` action requires `confirm=true`. Every `set` and `delete` first asks Caddy where the `id` resolves, and refuses one that does not resolve inside the config tree. If it resolves to the config root — normally because it is the config's own top-level `@id`, string or number — it names the **entire** config: `set` and `delete` with no subpath (or a lone `...`) then replace or unload the whole configuration, exactly as a root `caddy_config_set` / `caddy_config_delete` does — both require `confirm=true`, snapshot the prior config first, and report where the admin endpoint went. A subpath inside it is an ordinary write.
|
|
127
127
|
- **caddy_load** — Replace the entire config atomically. Runs on `CADDY_LOAD_TIMEOUT` (55 seconds by default), like every config change. Auto-snapshots the prior config, and keeps that snapshot when the load times out with its outcome unknown. Lists the Caddyfile adapter's warnings, and reports a load that failed as an error even when Caddy answered HTTP 200 — which Caddy 2.11.4 does for a Caddyfile that adapted with warnings ([caddyserver/caddy#7246](https://github.com/caddyserver/caddy/issues/7246)). `format` is `json` (default) or `caddyfile`; stock Caddy registers only the `caddyfile` adapter, so for any other adapter compiled into a custom build, adapt first with `caddy_adapt` and load the JSON (the Atomic deploy example below).
|
|
128
|
-
- **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10. Auto-captured before `caddy_load`, and before a `caddy_config_delete`
|
|
128
|
+
- **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10. Auto-captured before `caddy_load`, and before a `caddy_config_delete`, `caddy_config_set` or `caddy_config_by_id` that addresses the config root. An `apply` that times out with its outcome unknown keeps the pre-revert config as snapshot [0], which shifts every older snapshot down one index; the error says where the target now sits.
|
|
129
129
|
|
|
130
130
|
### Route operations (4)
|
|
131
131
|
|
|
@@ -284,9 +284,18 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
|
|
|
284
284
|
`mode: "insert"` on a route id, leaves two elements sharing one `@id`, and
|
|
285
285
|
`/id/` resolves to only one of them.
|
|
286
286
|
|
|
287
|
-
Older 2.x mostly works,
|
|
288
|
-
2.
|
|
289
|
-
|
|
287
|
+
Older 2.x mostly works, with two exceptions that both come from how Caddy sends
|
|
288
|
+
its `ETag`. Caddy 2.8.0 and later send it as a response header. Caddy 2.5.2
|
|
289
|
+
through 2.7.x send it as an HTTP trailer, which this client cannot read, and
|
|
290
|
+
earlier versions send none. So before 2.8.0:
|
|
291
|
+
- the `If-Match` (ETag) concurrency guard is inactive, because no ETag is ever
|
|
292
|
+
cached to echo back;
|
|
293
|
+
- every `caddy_config_by_id` `set` and `delete` is refused, because the tool
|
|
294
|
+
reads the ETag to learn where Caddy resolves the `@id` before it writes
|
|
295
|
+
through it, and fails closed when it cannot.
|
|
296
|
+
|
|
297
|
+
The `@id` write path relies on `PATCH` semantics that the live integration
|
|
298
|
+
suite pins per release.
|
|
290
299
|
|
|
291
300
|
## Contributing
|
|
292
301
|
|
|
@@ -297,7 +306,7 @@ npm install
|
|
|
297
306
|
npm run lint # Biome check
|
|
298
307
|
npm run lint:fix # Auto-fix
|
|
299
308
|
npm run build # tsup bundle
|
|
300
|
-
npm test # Vitest (
|
|
309
|
+
npm test # Vitest (781 unit tests, +39 POSIX-only unix-socket and launcher tests; +39 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
|
|
301
310
|
npm run typecheck # tsc --noEmit
|
|
302
311
|
```
|
|
303
312
|
|
|
@@ -307,4 +316,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release
|
|
|
307
316
|
|
|
308
317
|
MIT
|
|
309
318
|
|
|
310
|
-
[](https://x.com/YawLabs)
|
package/dist/api.d.ts
CHANGED
|
@@ -25,6 +25,37 @@ export interface ApiResponse<T = any> {
|
|
|
25
25
|
*/
|
|
26
26
|
outcomeUnknown?: boolean;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Does this config path address the ROOT -- the whole configuration?
|
|
30
|
+
*
|
|
31
|
+
* A write there is a different operation wearing the same request shape: PATCH
|
|
32
|
+
* and POST replace the entire config (Caddy sets `rawCfg["config"]` to the body,
|
|
33
|
+
* admin.go:1279 and :1296 at v2.11.4 -- exactly what `POST /load` does,
|
|
34
|
+
* caddy.go:136), and DELETE unloads it. Every tool that takes a config path has
|
|
35
|
+
* to know, because Caddy itself never distinguishes the root from a leaf.
|
|
36
|
+
*
|
|
37
|
+
* Root spellings, after normalizePath above has eaten one leading "/" and one
|
|
38
|
+
* "config/": nothing ("", "/", "config", "/config/"), only slashes ("//",
|
|
39
|
+
* "config//"), and any of those followed by a single "..." segment ("...",
|
|
40
|
+
* "/...", "config/...", "/config/.../"). The "..." form is Caddy's bulk-append
|
|
41
|
+
* spelling, and it is stripped BEFORE the method switch (admin.go:1196-1199),
|
|
42
|
+
* for every method, so `/config/...` lands on the same root branch as
|
|
43
|
+
* `/config/`. rejectTraversal below lets "..." through on purpose -- a trailing
|
|
44
|
+
* one under a real array is how `POST .../routes/...` appends in bulk -- so the
|
|
45
|
+
* root test has to absorb it here. Only ONE trailing "..." is stripped by Caddy:
|
|
46
|
+
* ".../..." writes a key literally named "...", which is a leaf.
|
|
47
|
+
*
|
|
48
|
+
* Verified against Caddy 2.11.4: PATCH and POST `/config/...` with an object
|
|
49
|
+
* body replaced the whole config, and DELETE `/config/...` unloaded it.
|
|
50
|
+
*
|
|
51
|
+
* Callers that branch on this must SEND the canonical "" rather than the
|
|
52
|
+
* caller's spelling (see caddy_config_delete in tools/config.ts): "//" would be
|
|
53
|
+
* encoded to `/config//`, which Go's ServeMux answers with a redirect to
|
|
54
|
+
* `/config/` instead of dispatching, so the ETag cached under "/config/" is
|
|
55
|
+
* never found and the request goes out with no If-Match; over a unix socket
|
|
56
|
+
* node:http does not follow the redirect at all.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isRootConfigPath(path: string): boolean;
|
|
28
59
|
/**
|
|
29
60
|
* Caddy's "a segment of this config path does not exist" failure.
|
|
30
61
|
*
|
|
@@ -55,6 +86,29 @@ export declare function stop(): Promise<ApiResponse>;
|
|
|
55
86
|
export declare function getUpstreams(): Promise<ApiResponse>;
|
|
56
87
|
export declare function getPki(ca?: string): Promise<ApiResponse>;
|
|
57
88
|
export declare function getPkiCertificates(ca?: string): Promise<ApiResponse>;
|
|
89
|
+
/**
|
|
90
|
+
* The config path an @id resolves to, read from the ETag of `GET /id/<id>/@id`.
|
|
91
|
+
*
|
|
92
|
+
* Caddy decides where `/id/<id>` goes from its own index, not from anything a
|
|
93
|
+
* client can reconstruct: it builds each indexed path with `path.Join`, which
|
|
94
|
+
* collapses ".." INSIDE key names (caddy.go:311 at v2.11.4), and keys a numeric
|
|
95
|
+
* "@id" by its Go `%v` string. So a nested object under a key like "../../.."
|
|
96
|
+
* resolves to the config ROOT, and `{"@id": 7}` makes `/id/7` the root while a
|
|
97
|
+
* read of the top-level "@id" answers the number 7. The one reliable answer is
|
|
98
|
+
* where Caddy actually sends the request: handleConfigID rewrites the path and
|
|
99
|
+
* re-dispatches it internally, and the GET that lands in handleConfig writes an
|
|
100
|
+
* ETag of `"<that path> <hash>"` (makeEtag). Appending "@id" keeps the read to a
|
|
101
|
+
* few bytes. Verified against Caddy 2.11.4: a string or numeric top-level @id
|
|
102
|
+
* and a "../../.."-nested one all answer `"/config/@id <hash>"`; a route answers
|
|
103
|
+
* its own path; an id that collapses OUTSIDE the config tree (to `/load` or `/`)
|
|
104
|
+
* answers 404 with no ETag, because no config handler serves it.
|
|
105
|
+
*
|
|
106
|
+
* Returns the resolved object's path ("/config" for the root), or undefined when
|
|
107
|
+
* the ETag is absent or not of that shape. Header bytes arrive as latin1, like
|
|
108
|
+
* isEchoableEtag's input; the hash never contains a space, so the LAST space is
|
|
109
|
+
* the separator even when a key in the path contains whitespace.
|
|
110
|
+
*/
|
|
111
|
+
export declare function idObjectPath(etag: string | undefined): string | undefined;
|
|
58
112
|
export declare function configByIdGet<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
|
|
59
113
|
export declare function configByIdSet<T = any>(id: string, value: unknown, method?: "POST" | "PATCH" | "PUT", subpath?: string): Promise<ApiResponse<T>>;
|
|
60
114
|
export declare function configByIdDelete<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
|
package/dist/index.js
CHANGED
|
@@ -138,15 +138,18 @@ function getHeaders(contentType, overUnixSocket = false) {
|
|
|
138
138
|
function normalizePath(path) {
|
|
139
139
|
return path.replace(/^\/?(config(\/|$))?/, "");
|
|
140
140
|
}
|
|
141
|
+
function isRootConfigPath(path) {
|
|
142
|
+
return /^\/*(\.\.\.\/*)?$/.test(normalizePath(path));
|
|
143
|
+
}
|
|
141
144
|
function encodePathSegments(path) {
|
|
142
145
|
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
143
146
|
}
|
|
144
147
|
function rejectTraversal(path) {
|
|
145
|
-
if (/(^|\/)
|
|
148
|
+
if (/(^|\/)\.\.?(\/|$)/.test(path)) {
|
|
146
149
|
return {
|
|
147
150
|
ok: false,
|
|
148
151
|
status: 0,
|
|
149
|
-
error: `Invalid path "${path}": '..' segments are not allowed`
|
|
152
|
+
error: `Invalid path "${path}": '.' and '..' segments are not allowed`
|
|
150
153
|
};
|
|
151
154
|
}
|
|
152
155
|
return null;
|
|
@@ -554,6 +557,16 @@ function idPath(id, subpath) {
|
|
|
554
557
|
const encodedId = encodePathSegments(id);
|
|
555
558
|
return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
|
|
556
559
|
}
|
|
560
|
+
function idObjectPath(etag) {
|
|
561
|
+
if (!etag) return void 0;
|
|
562
|
+
const decoded = Buffer.from(etag, "latin1").toString("utf8");
|
|
563
|
+
if (decoded.length < 2 || !decoded.startsWith('"') || !decoded.endsWith('"')) return void 0;
|
|
564
|
+
const inner = decoded.slice(1, -1);
|
|
565
|
+
const cut = inner.lastIndexOf(" ");
|
|
566
|
+
if (cut <= 0) return void 0;
|
|
567
|
+
const path = inner.slice(0, cut);
|
|
568
|
+
return path.endsWith("/@id") ? path.slice(0, -"/@id".length) : void 0;
|
|
569
|
+
}
|
|
557
570
|
function configByIdGet(id, subpath = "") {
|
|
558
571
|
const badId = rejectTraversal(id);
|
|
559
572
|
if (badId) return Promise.resolve(badId);
|
|
@@ -1076,8 +1089,126 @@ function getSnapshot(index) {
|
|
|
1076
1089
|
}
|
|
1077
1090
|
|
|
1078
1091
|
// src/tools/config.ts
|
|
1079
|
-
function
|
|
1080
|
-
|
|
1092
|
+
function adminListenOf(config) {
|
|
1093
|
+
if (!isSnapshotableConfig(config)) return void 0;
|
|
1094
|
+
const adm = config.admin;
|
|
1095
|
+
if (adm === null || typeof adm !== "object" || Array.isArray(adm)) return void 0;
|
|
1096
|
+
const listen = adm.listen;
|
|
1097
|
+
return typeof listen === "string" && listen !== "" ? listen : void 0;
|
|
1098
|
+
}
|
|
1099
|
+
function adminDisabledIn(config) {
|
|
1100
|
+
if (!isSnapshotableConfig(config)) return false;
|
|
1101
|
+
const adm = config.admin;
|
|
1102
|
+
return adm !== null && typeof adm === "object" && !Array.isArray(adm) && adm.disabled === true;
|
|
1103
|
+
}
|
|
1104
|
+
function priorConfigNote(kept, current, trigger) {
|
|
1105
|
+
if (kept) {
|
|
1106
|
+
return ` The prior config was saved as snapshot [0] (trigger=${trigger}): caddy_revert { action: "apply", index: 0, confirm: true } restores it.`;
|
|
1107
|
+
}
|
|
1108
|
+
return current.ok ? " Warning: the prior config was empty or not a JSON object, so no snapshot was captured -- there was nothing to restore." : " Warning: the prior config could not be read, so no snapshot was captured and this change cannot be reverted.";
|
|
1109
|
+
}
|
|
1110
|
+
function rootWriteAdminNote(current, value) {
|
|
1111
|
+
if (adminDisabledIn(value)) {
|
|
1112
|
+
return " The new config sets admin.disabled, so Caddy no longer answers on ANY admin address: neither this server nor caddy_revert can reach it until Caddy is restarted with a config that does not disable the admin endpoint.";
|
|
1113
|
+
}
|
|
1114
|
+
const adminUrl = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
|
|
1115
|
+
const before = current.ok ? adminListenOf(current.data) : void 0;
|
|
1116
|
+
const after = adminListenOf(value);
|
|
1117
|
+
if (before !== void 0 && after !== before) {
|
|
1118
|
+
return ` The replaced config set admin.listen to "${before}"` + (after !== void 0 ? ` and the new one sets it to "${after}".` : " and the new one sets none.") + ` Unless those resolve to the same address, the admin endpoint has moved (to the new value, or back to the default: localhost:2019 unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${adminUrl}) may no longer reach it.`;
|
|
1119
|
+
}
|
|
1120
|
+
if (before === void 0 && after !== void 0) {
|
|
1121
|
+
return ` The new config sets admin.listen to "${after}". If that is not the address Caddy was already on, the admin endpoint has moved there and CADDY_ADMIN_URL (${adminUrl}) may no longer reach it.`;
|
|
1122
|
+
}
|
|
1123
|
+
if (!current.ok) return unreadablePriorAdminNote("replaced");
|
|
1124
|
+
return "";
|
|
1125
|
+
}
|
|
1126
|
+
function unreadablePriorAdminNote(verb) {
|
|
1127
|
+
return ` The ${verb} config could not be read, so this server cannot tell whether it set an admin.listen of its own. If it did, and that was not Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.`;
|
|
1128
|
+
}
|
|
1129
|
+
function writeAt(mode, path, value) {
|
|
1130
|
+
return mode === "overwrite" ? configPatch(path, value) : mode === "insert" ? configPut(path, value) : configPost(path, value);
|
|
1131
|
+
}
|
|
1132
|
+
function rootWriteRefusal(target, narrower) {
|
|
1133
|
+
return {
|
|
1134
|
+
isError: true,
|
|
1135
|
+
content: [
|
|
1136
|
+
{
|
|
1137
|
+
type: "text",
|
|
1138
|
+
text: `Refusing to write the ENTIRE config without confirm=true. ${target} This call REPLACES the whole configuration with \`value\` ('overwrite' and 'append' both do, and Caddy runs it exactly as caddy_load would; after a root caddy_config_delete 'overwrite' answers 404 instead, while 'insert' succeeds only then and answers 409 otherwise): every app, server and route not in \`value\` is discarded, and so is the 'admin' block unless \`value\` carries one. Caddy then re-binds its admin endpoint to the new config's admin.listen, or to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment) when it sets none, or to no address at all when it sets admin.disabled; and if CADDY_ADMIN_URL points anywhere else neither this server nor caddy_revert can reach Caddy afterwards. The current config is snapshotted first, so caddy_revert can restore it while Caddy is still reachable. To change one part of the config, ${narrower}; to replace all of it deliberately, caddy_load does the same behind the same gate. Re-run with confirm:true to proceed.`
|
|
1139
|
+
}
|
|
1140
|
+
]
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
async function rootConfigWrite(mode, value, trigger) {
|
|
1144
|
+
const current = await configGet();
|
|
1145
|
+
const res = await writeAt(mode, "", value);
|
|
1146
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1147
|
+
if (kept) saveSnapshot(current.data, trigger);
|
|
1148
|
+
const adminNote = rootWriteAdminNote(current, value);
|
|
1149
|
+
if (!res.ok) {
|
|
1150
|
+
if (res.outcomeUnknown && (kept || adminNote)) {
|
|
1151
|
+
const snapshotPart = kept ? `The pre-write config was kept anyway, as snapshot [0] (trigger=${trigger}): if this write did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it replaced, as long as this server can still reach Caddy's admin endpoint.` : "";
|
|
1152
|
+
const adminPart = adminNote ? `If the write applied:${adminNote}` : "";
|
|
1153
|
+
const tail = kept ? "If it did not apply, that snapshot is simply the config read just before it was sent." : "";
|
|
1154
|
+
return formatResult({
|
|
1155
|
+
...res,
|
|
1156
|
+
error: `${res.error}
|
|
1157
|
+
${[snapshotPart, adminPart, tail].filter(Boolean).join(" ")}`
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return formatResult(res);
|
|
1161
|
+
}
|
|
1162
|
+
const note = priorConfigNote(kept, current, trigger);
|
|
1163
|
+
return {
|
|
1164
|
+
content: [
|
|
1165
|
+
{
|
|
1166
|
+
type: "text",
|
|
1167
|
+
text: `Replaced the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1168
|
+
}
|
|
1169
|
+
]
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
function rootDeleteRefusal(target) {
|
|
1173
|
+
return {
|
|
1174
|
+
isError: true,
|
|
1175
|
+
content: [
|
|
1176
|
+
{
|
|
1177
|
+
type: "text",
|
|
1178
|
+
text: `Refusing to delete the ENTIRE config without confirm=true. ${target} This unloads every app and server, and the 'admin' block with them: Caddy then re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment), and if CADDY_ADMIN_URL points anywhere else neither this server nor caddy_revert can reach Caddy afterwards. The current config is snapshotted first, so caddy_revert can restore it while Caddy is still reachable. To REPLACE the config rather than unload it, use caddy_load. Re-run with confirm:true to proceed.`
|
|
1179
|
+
}
|
|
1180
|
+
]
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
async function rootConfigDelete(trigger) {
|
|
1184
|
+
const current = await configGet();
|
|
1185
|
+
const res = await configDelete("");
|
|
1186
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1187
|
+
if (kept) saveSnapshot(current.data, trigger);
|
|
1188
|
+
if (!res.ok) {
|
|
1189
|
+
if (res.outcomeUnknown && kept) {
|
|
1190
|
+
return formatResult({
|
|
1191
|
+
...res,
|
|
1192
|
+
error: `${res.error}
|
|
1193
|
+
The pre-delete config was kept anyway, as snapshot [0] (trigger=${trigger}): if this delete did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it unloaded -- as long as this server can still reach Caddy's admin endpoint, which moves back to Caddy's default address when the deleted config set an admin.listen of its own. If the delete did not apply, that snapshot is simply the config read just before it was sent.`
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
return formatResult(res);
|
|
1197
|
+
}
|
|
1198
|
+
const note = priorConfigNote(kept, current, trigger);
|
|
1199
|
+
const listen = kept ? adminListenOf(current.data) : void 0;
|
|
1200
|
+
const adminNote = listen !== void 0 ? ` The unloaded config set admin.listen to "${listen}". If that was not already Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.` : current.ok ? "" : unreadablePriorAdminNote("unloaded");
|
|
1201
|
+
return {
|
|
1202
|
+
content: [
|
|
1203
|
+
{
|
|
1204
|
+
type: "text",
|
|
1205
|
+
text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1206
|
+
}
|
|
1207
|
+
]
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function isAtIdentifiedObject(subpath) {
|
|
1211
|
+
return /^\/*(\.\.\.\/*)?$/.test(subpath);
|
|
1081
1212
|
}
|
|
1082
1213
|
function registerConfigTools(server) {
|
|
1083
1214
|
server.tool(
|
|
@@ -1089,23 +1220,41 @@ function registerConfigTools(server) {
|
|
|
1089
1220
|
);
|
|
1090
1221
|
server.tool(
|
|
1091
1222
|
"caddy_config_set",
|
|
1092
|
-
"Write config at a JSON path. Mode 'overwrite' (default) replaces
|
|
1223
|
+
"Write config at a JSON path. Mode 'overwrite' (default) replaces the value at the path (PATCH) \u2014 idempotent, but it replaces the WHOLE subtree there: writing [] to '.../routes' drops every route. Mode 'append' (POST) adds to an array \u2014 NOT idempotent: calling twice with the same route duplicates it \u2014 but on a non-array key it REPLACES whatever is there, and it cannot create missing parent objects. Mode 'insert' (PUT) inserts at an array index (useful for route ordering), or strictly creates an object key together with any missing parents and fails with 409 if the key already exists \u2014 the safe way to create a server or app, including on an instance with no config at all. Any path that addresses the config ROOT ('', '/', 'config', '/config/', a slash-only variant, or any of those followed by a lone '...' segment) addresses the ENTIRE config and requires confirm=true: 'overwrite' and 'append' there REPLACE the whole configuration with `value` \u2014 Caddy runs it exactly as caddy_load would \u2014 so every app, server and route not in `value` is discarded, and so is the 'admin' block unless `value` carries one. Caddy then re-binds its admin endpoint to the new config's admin.listen, or to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment) when it sets none, or to no address at all when it sets admin.disabled; if CADDY_ADMIN_URL points anywhere else, neither this server nor caddy_revert can reach Caddy afterwards. A root write is snapshotted first, so caddy_revert can restore it while Caddy is still reachable; no other path is snapshotted. 'insert' at the root only succeeds after a root caddy_config_delete (Caddy answers 409 on any other instance, including one started with no config), and 'overwrite' is the reverse: after a root caddy_config_delete it answers 404 until 'append', 'insert' or caddy_load re-creates the config. To replace the whole config deliberately, caddy_load does the same thing behind the same gate and also accepts a Caddyfile.",
|
|
1093
1224
|
{
|
|
1094
1225
|
path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
1095
1226
|
value: z3.any().describe("The JSON value to set at the path"),
|
|
1096
1227
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
1097
1228
|
"'overwrite' = PATCH (replace existing, default, idempotent; 404 if the key does not exist), 'append' = POST (appends to arrays, NOT idempotent; REPLACES an existing non-array key; cannot create missing parents), 'insert' = PUT (inserts at an array index, or strictly creates an object key and any missing parents; 409 if the key exists)"
|
|
1229
|
+
),
|
|
1230
|
+
confirm: z3.boolean().optional().default(false).describe(
|
|
1231
|
+
"Must be true when `path` addresses the config root ('', '/', 'config', '/config/', slash-only variants, or a lone '...' segment): that write replaces the ENTIRE config (safety). Ignored for every other path."
|
|
1098
1232
|
)
|
|
1099
1233
|
},
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1234
|
+
// destructiveHint is TRUE because the hint covers the whole tool and a host
|
|
1235
|
+
// gates on it before it can see the path or the mode: the default mode is a
|
|
1236
|
+
// PATCH that replaces whatever subtree the path names -- a route list, a
|
|
1237
|
+
// server, all of `apps` -- and at the root it replaces the entire config
|
|
1238
|
+
// (below). MCP defines destructiveHint:false as "only additive updates".
|
|
1239
|
+
// Appending to an array or strictly creating a key is additive; the
|
|
1240
|
+
// default mode is not, and neither is 'append' on a non-array key, which
|
|
1241
|
+
// replaces it. idempotentHint stays false for 'append', which is a POST: a
|
|
1242
|
+
// repeated call appends a second copy.
|
|
1243
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1244
|
+
async ({ path, value, mode, confirm }) => {
|
|
1245
|
+
if (!isRootConfigPath(path)) return formatResult(await writeAt(mode, path, value));
|
|
1246
|
+
if (!confirm) {
|
|
1247
|
+
return rootWriteRefusal(
|
|
1248
|
+
`The path "${path}" addresses the config root.`,
|
|
1249
|
+
"pass its path instead (e.g. 'apps/http/servers/srv0')"
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
return rootConfigWrite(mode, value, "caddy_config_set");
|
|
1104
1253
|
}
|
|
1105
1254
|
);
|
|
1106
1255
|
server.tool(
|
|
1107
1256
|
"caddy_config_delete",
|
|
1108
|
-
"Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true. Any path that addresses the config ROOT ('', '/', 'config', '/config/',
|
|
1257
|
+
"Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true. Any path that addresses the config ROOT ('', '/', 'config', '/config/', slash-only variants of those, or any of those followed by a lone '...' segment) addresses the ENTIRE config and unloads it: every app and server goes, and so does the 'admin' block, after which Caddy re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment). If CADDY_ADMIN_URL points anywhere else, neither this server nor caddy_revert can reach Caddy afterwards. A root delete is snapshotted first, so caddy_revert can restore it while Caddy is still reachable; no other path is snapshotted. To REPLACE the config rather than unload it, use caddy_load.",
|
|
1109
1258
|
{
|
|
1110
1259
|
path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
|
|
1111
1260
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
|
|
@@ -1116,49 +1265,27 @@ function registerConfigTools(server) {
|
|
|
1116
1265
|
// array after a delete, so repeating that call removes a DIFFERENT route each
|
|
1117
1266
|
// time. caddy_remove_route carries the same correction for the byte-identical
|
|
1118
1267
|
// underlying request; the two must agree. Nothing here is auto-recoverable
|
|
1119
|
-
// either: apart from a root delete (below), only
|
|
1120
|
-
// snapshot,
|
|
1268
|
+
// either: apart from a root delete (below), only whole-config changes
|
|
1269
|
+
// capture a snapshot -- caddy_load, a caddy_revert apply, and a root
|
|
1270
|
+
// caddy_config_set -- so a spurious repeat cannot be undone with
|
|
1271
|
+
// caddy_revert.
|
|
1121
1272
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1122
1273
|
async ({ path, confirm }) => {
|
|
1123
1274
|
const root = isRootConfigPath(path);
|
|
1124
1275
|
if (!confirm) {
|
|
1276
|
+
if (root) return rootDeleteRefusal(`The path "${path}" addresses the config root.`);
|
|
1125
1277
|
return {
|
|
1126
1278
|
isError: true,
|
|
1127
1279
|
content: [
|
|
1128
1280
|
{
|
|
1129
1281
|
type: "text",
|
|
1130
|
-
text:
|
|
1282
|
+
text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
|
|
1131
1283
|
}
|
|
1132
1284
|
]
|
|
1133
1285
|
};
|
|
1134
1286
|
}
|
|
1135
1287
|
if (!root) return formatResult(await configDelete(path));
|
|
1136
|
-
|
|
1137
|
-
const res = await configDelete("");
|
|
1138
|
-
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1139
|
-
if (kept) saveSnapshot(current.data, "caddy_config_delete");
|
|
1140
|
-
if (!res.ok) {
|
|
1141
|
-
if (res.outcomeUnknown && kept) {
|
|
1142
|
-
return formatResult({
|
|
1143
|
-
...res,
|
|
1144
|
-
error: `${res.error}
|
|
1145
|
-
The pre-delete config was kept anyway, as snapshot [0] (trigger=caddy_config_delete): if this delete did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it unloaded -- as long as this server can still reach Caddy's admin endpoint, which moves back to Caddy's default address when the deleted config set an admin.listen of its own. If the delete did not apply, that snapshot is simply the config read just before it was sent.`
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
return formatResult(res);
|
|
1149
|
-
}
|
|
1150
|
-
const note = kept ? ` The prior config was saved as snapshot [0] (trigger=caddy_config_delete): caddy_revert { action: "apply", index: 0, confirm: true } restores it.` : current.ok ? " Warning: the prior config was empty or not a JSON object, so no snapshot was captured -- there was nothing to restore." : " Warning: the prior config could not be read, so no snapshot was captured and this unload cannot be reverted.";
|
|
1151
|
-
const adm = kept ? current.data.admin : void 0;
|
|
1152
|
-
const listen = adm !== null && typeof adm === "object" && !Array.isArray(adm) ? adm.listen : void 0;
|
|
1153
|
-
const adminNote = typeof listen === "string" && listen !== "" ? ` The unloaded config set admin.listen to "${listen}". If that was not already Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.` : "";
|
|
1154
|
-
return {
|
|
1155
|
-
content: [
|
|
1156
|
-
{
|
|
1157
|
-
type: "text",
|
|
1158
|
-
text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1159
|
-
}
|
|
1160
|
-
]
|
|
1161
|
-
};
|
|
1288
|
+
return rootConfigDelete("caddy_config_delete");
|
|
1162
1289
|
}
|
|
1163
1290
|
);
|
|
1164
1291
|
server.tool(
|
|
@@ -1199,7 +1326,7 @@ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if th
|
|
|
1199
1326
|
);
|
|
1200
1327
|
server.tool(
|
|
1201
1328
|
"caddy_revert",
|
|
1202
|
-
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete that
|
|
1329
|
+
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete or caddy_config_set at the config root (any path that addresses the whole config), and before a caddy_config_by_id set or delete whose @id resolves to the root with no subpath (or a lone '...'); no other delete or set is snapshotted. Last 10. By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
|
|
1203
1330
|
{
|
|
1204
1331
|
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
1205
1332
|
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
@@ -1291,7 +1418,7 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
|
|
|
1291
1418
|
);
|
|
1292
1419
|
server.tool(
|
|
1293
1420
|
"caddy_config_by_id",
|
|
1294
|
-
"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. The 'delete' action requires confirm=true.",
|
|
1421
|
+
"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. The 'delete' action requires confirm=true. Every 'set' and 'delete' first asks Caddy where `id` resolves, and refuses an id that does not resolve inside the config tree (including an unknown one). If Caddy resolves `id` to the config ROOT -- normally because it is the config's own top-level '@id', string or number -- it names the ENTIRE config: 'set' and 'delete' with no subpath (or a lone '...') then act on the whole configuration exactly as caddy_config_set and caddy_config_delete do at the config root -- 'set' replaces it and 'delete' unloads it, the 'admin' block included, so Caddy's admin endpoint can move and CADDY_ADMIN_URL stop reaching it. Both then require confirm=true, snapshot the prior config first so caddy_revert can restore it, and report where the admin endpoint went. A subpath inside it (e.g. 'apps/http') is an ordinary write.",
|
|
1295
1422
|
{
|
|
1296
1423
|
id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
|
|
1297
1424
|
action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
@@ -1300,7 +1427,9 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
|
|
|
1300
1427
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
1301
1428
|
"For 'set' action: 'overwrite' = PATCH (replace the identified object, or the value at subpath; default). 'append' = POST and 'insert' = PUT behave as in caddy_config_set at the resolved path: with a subpath into an array, POST appends and PUT inserts at the index; PUT also strictly creates an object key (409 if it exists). With NO subpath: for an array element (a route) neither replaces it \u2014 POST adds the value as a new element at the end of that array, PUT inserts it just before the identified one, and both are rejected with 'duplicate ID' if the value carries the same @id; for an object held under a key (a server) POST REPLACES it wholesale and PUT fails with 409. Use 'overwrite' to replace in place."
|
|
1302
1429
|
),
|
|
1303
|
-
confirm: z3.boolean().optional().default(false).describe(
|
|
1430
|
+
confirm: z3.boolean().optional().default(false).describe(
|
|
1431
|
+
"Must be true to actually delete, and to 'set' when Caddy resolves `id` to the config root (that replaces the ENTIRE config). Ignored for every other 'set'."
|
|
1432
|
+
)
|
|
1304
1433
|
},
|
|
1305
1434
|
// destructiveHint is keyed to the worst thing this tool can do, not the
|
|
1306
1435
|
// default action: action='delete' removes the identified object and every
|
|
@@ -1313,13 +1442,33 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
|
|
|
1313
1442
|
if (action === "get") {
|
|
1314
1443
|
return formatResult(await configByIdGet(id, subpath));
|
|
1315
1444
|
}
|
|
1316
|
-
if (action === "set") {
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1445
|
+
if (action === "set" && value === void 0) {
|
|
1446
|
+
return {
|
|
1447
|
+
isError: true,
|
|
1448
|
+
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
const probe = await configByIdGet(id, "@id");
|
|
1452
|
+
if (!probe.ok) {
|
|
1453
|
+
return formatResult({
|
|
1454
|
+
...probe,
|
|
1455
|
+
error: `Could not check where Caddy resolves @id "${id}", so nothing was changed.
|
|
1456
|
+
${probe.error}`
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
const objectPath = idObjectPath(probe.etag);
|
|
1460
|
+
if (objectPath === void 0 || !(objectPath === "/config" || objectPath.startsWith("/config/"))) {
|
|
1461
|
+
const why = !probe.etag ? `Caddy's answer to a read of @id "${id}" carried no ETag header, so this tool cannot tell where Caddy resolves that id or what a write through it would change. Caddy sends that header from 2.8.0 on; earlier versions send it as a trailer or not at all, and a proxy in front of Caddy can strip it.` : objectPath !== void 0 ? `Caddy resolves @id "${id}" to ${objectPath || "/"}, which is outside the config tree, so this tool will not write through it.` : `Caddy's answer to a read of @id "${id}" carried an ETag this tool could not read a config path from, so it cannot tell what a write through that id would change.`;
|
|
1462
|
+
return { isError: true, content: [{ type: "text", text: `${why} Nothing was changed.` }] };
|
|
1463
|
+
}
|
|
1464
|
+
if (objectPath === "/config" && isAtIdentifiedObject(subpath)) {
|
|
1465
|
+
const target = `Caddy resolves @id "${id}" to the config root.`;
|
|
1466
|
+
if (action === "set") {
|
|
1467
|
+
return confirm ? rootConfigWrite(mode, value, "caddy_config_by_id") : rootWriteRefusal(target, "pass a subpath inside it instead (e.g. subpath 'apps/http')");
|
|
1322
1468
|
}
|
|
1469
|
+
return confirm ? rootConfigDelete("caddy_config_by_id") : rootDeleteRefusal(target);
|
|
1470
|
+
}
|
|
1471
|
+
if (action === "set") {
|
|
1323
1472
|
const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
|
|
1324
1473
|
return formatResult(await configByIdSet(id, value, method, subpath));
|
|
1325
1474
|
}
|
package/dist/server.js
CHANGED
|
@@ -136,15 +136,18 @@ function getHeaders(contentType, overUnixSocket = false) {
|
|
|
136
136
|
function normalizePath(path) {
|
|
137
137
|
return path.replace(/^\/?(config(\/|$))?/, "");
|
|
138
138
|
}
|
|
139
|
+
function isRootConfigPath(path) {
|
|
140
|
+
return /^\/*(\.\.\.\/*)?$/.test(normalizePath(path));
|
|
141
|
+
}
|
|
139
142
|
function encodePathSegments(path) {
|
|
140
143
|
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
141
144
|
}
|
|
142
145
|
function rejectTraversal(path) {
|
|
143
|
-
if (/(^|\/)
|
|
146
|
+
if (/(^|\/)\.\.?(\/|$)/.test(path)) {
|
|
144
147
|
return {
|
|
145
148
|
ok: false,
|
|
146
149
|
status: 0,
|
|
147
|
-
error: `Invalid path "${path}": '..' segments are not allowed`
|
|
150
|
+
error: `Invalid path "${path}": '.' and '..' segments are not allowed`
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
153
|
return null;
|
|
@@ -552,6 +555,16 @@ function idPath(id, subpath) {
|
|
|
552
555
|
const encodedId = encodePathSegments(id);
|
|
553
556
|
return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
|
|
554
557
|
}
|
|
558
|
+
function idObjectPath(etag) {
|
|
559
|
+
if (!etag) return void 0;
|
|
560
|
+
const decoded = Buffer.from(etag, "latin1").toString("utf8");
|
|
561
|
+
if (decoded.length < 2 || !decoded.startsWith('"') || !decoded.endsWith('"')) return void 0;
|
|
562
|
+
const inner = decoded.slice(1, -1);
|
|
563
|
+
const cut = inner.lastIndexOf(" ");
|
|
564
|
+
if (cut <= 0) return void 0;
|
|
565
|
+
const path = inner.slice(0, cut);
|
|
566
|
+
return path.endsWith("/@id") ? path.slice(0, -"/@id".length) : void 0;
|
|
567
|
+
}
|
|
555
568
|
function configByIdGet(id, subpath = "") {
|
|
556
569
|
const badId = rejectTraversal(id);
|
|
557
570
|
if (badId) return Promise.resolve(badId);
|
|
@@ -1074,8 +1087,126 @@ function getSnapshot(index) {
|
|
|
1074
1087
|
}
|
|
1075
1088
|
|
|
1076
1089
|
// src/tools/config.ts
|
|
1077
|
-
function
|
|
1078
|
-
|
|
1090
|
+
function adminListenOf(config) {
|
|
1091
|
+
if (!isSnapshotableConfig(config)) return void 0;
|
|
1092
|
+
const adm = config.admin;
|
|
1093
|
+
if (adm === null || typeof adm !== "object" || Array.isArray(adm)) return void 0;
|
|
1094
|
+
const listen = adm.listen;
|
|
1095
|
+
return typeof listen === "string" && listen !== "" ? listen : void 0;
|
|
1096
|
+
}
|
|
1097
|
+
function adminDisabledIn(config) {
|
|
1098
|
+
if (!isSnapshotableConfig(config)) return false;
|
|
1099
|
+
const adm = config.admin;
|
|
1100
|
+
return adm !== null && typeof adm === "object" && !Array.isArray(adm) && adm.disabled === true;
|
|
1101
|
+
}
|
|
1102
|
+
function priorConfigNote(kept, current, trigger) {
|
|
1103
|
+
if (kept) {
|
|
1104
|
+
return ` The prior config was saved as snapshot [0] (trigger=${trigger}): caddy_revert { action: "apply", index: 0, confirm: true } restores it.`;
|
|
1105
|
+
}
|
|
1106
|
+
return current.ok ? " Warning: the prior config was empty or not a JSON object, so no snapshot was captured -- there was nothing to restore." : " Warning: the prior config could not be read, so no snapshot was captured and this change cannot be reverted.";
|
|
1107
|
+
}
|
|
1108
|
+
function rootWriteAdminNote(current, value) {
|
|
1109
|
+
if (adminDisabledIn(value)) {
|
|
1110
|
+
return " The new config sets admin.disabled, so Caddy no longer answers on ANY admin address: neither this server nor caddy_revert can reach it until Caddy is restarted with a config that does not disable the admin endpoint.";
|
|
1111
|
+
}
|
|
1112
|
+
const adminUrl = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
|
|
1113
|
+
const before = current.ok ? adminListenOf(current.data) : void 0;
|
|
1114
|
+
const after = adminListenOf(value);
|
|
1115
|
+
if (before !== void 0 && after !== before) {
|
|
1116
|
+
return ` The replaced config set admin.listen to "${before}"` + (after !== void 0 ? ` and the new one sets it to "${after}".` : " and the new one sets none.") + ` Unless those resolve to the same address, the admin endpoint has moved (to the new value, or back to the default: localhost:2019 unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${adminUrl}) may no longer reach it.`;
|
|
1117
|
+
}
|
|
1118
|
+
if (before === void 0 && after !== void 0) {
|
|
1119
|
+
return ` The new config sets admin.listen to "${after}". If that is not the address Caddy was already on, the admin endpoint has moved there and CADDY_ADMIN_URL (${adminUrl}) may no longer reach it.`;
|
|
1120
|
+
}
|
|
1121
|
+
if (!current.ok) return unreadablePriorAdminNote("replaced");
|
|
1122
|
+
return "";
|
|
1123
|
+
}
|
|
1124
|
+
function unreadablePriorAdminNote(verb) {
|
|
1125
|
+
return ` The ${verb} config could not be read, so this server cannot tell whether it set an admin.listen of its own. If it did, and that was not Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.`;
|
|
1126
|
+
}
|
|
1127
|
+
function writeAt(mode, path, value) {
|
|
1128
|
+
return mode === "overwrite" ? configPatch(path, value) : mode === "insert" ? configPut(path, value) : configPost(path, value);
|
|
1129
|
+
}
|
|
1130
|
+
function rootWriteRefusal(target, narrower) {
|
|
1131
|
+
return {
|
|
1132
|
+
isError: true,
|
|
1133
|
+
content: [
|
|
1134
|
+
{
|
|
1135
|
+
type: "text",
|
|
1136
|
+
text: `Refusing to write the ENTIRE config without confirm=true. ${target} This call REPLACES the whole configuration with \`value\` ('overwrite' and 'append' both do, and Caddy runs it exactly as caddy_load would; after a root caddy_config_delete 'overwrite' answers 404 instead, while 'insert' succeeds only then and answers 409 otherwise): every app, server and route not in \`value\` is discarded, and so is the 'admin' block unless \`value\` carries one. Caddy then re-binds its admin endpoint to the new config's admin.listen, or to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment) when it sets none, or to no address at all when it sets admin.disabled; and if CADDY_ADMIN_URL points anywhere else neither this server nor caddy_revert can reach Caddy afterwards. The current config is snapshotted first, so caddy_revert can restore it while Caddy is still reachable. To change one part of the config, ${narrower}; to replace all of it deliberately, caddy_load does the same behind the same gate. Re-run with confirm:true to proceed.`
|
|
1137
|
+
}
|
|
1138
|
+
]
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
async function rootConfigWrite(mode, value, trigger) {
|
|
1142
|
+
const current = await configGet();
|
|
1143
|
+
const res = await writeAt(mode, "", value);
|
|
1144
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1145
|
+
if (kept) saveSnapshot(current.data, trigger);
|
|
1146
|
+
const adminNote = rootWriteAdminNote(current, value);
|
|
1147
|
+
if (!res.ok) {
|
|
1148
|
+
if (res.outcomeUnknown && (kept || adminNote)) {
|
|
1149
|
+
const snapshotPart = kept ? `The pre-write config was kept anyway, as snapshot [0] (trigger=${trigger}): if this write did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it replaced, as long as this server can still reach Caddy's admin endpoint.` : "";
|
|
1150
|
+
const adminPart = adminNote ? `If the write applied:${adminNote}` : "";
|
|
1151
|
+
const tail = kept ? "If it did not apply, that snapshot is simply the config read just before it was sent." : "";
|
|
1152
|
+
return formatResult({
|
|
1153
|
+
...res,
|
|
1154
|
+
error: `${res.error}
|
|
1155
|
+
${[snapshotPart, adminPart, tail].filter(Boolean).join(" ")}`
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
return formatResult(res);
|
|
1159
|
+
}
|
|
1160
|
+
const note = priorConfigNote(kept, current, trigger);
|
|
1161
|
+
return {
|
|
1162
|
+
content: [
|
|
1163
|
+
{
|
|
1164
|
+
type: "text",
|
|
1165
|
+
text: `Replaced the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1166
|
+
}
|
|
1167
|
+
]
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
function rootDeleteRefusal(target) {
|
|
1171
|
+
return {
|
|
1172
|
+
isError: true,
|
|
1173
|
+
content: [
|
|
1174
|
+
{
|
|
1175
|
+
type: "text",
|
|
1176
|
+
text: `Refusing to delete the ENTIRE config without confirm=true. ${target} This unloads every app and server, and the 'admin' block with them: Caddy then re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment), and if CADDY_ADMIN_URL points anywhere else neither this server nor caddy_revert can reach Caddy afterwards. The current config is snapshotted first, so caddy_revert can restore it while Caddy is still reachable. To REPLACE the config rather than unload it, use caddy_load. Re-run with confirm:true to proceed.`
|
|
1177
|
+
}
|
|
1178
|
+
]
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
async function rootConfigDelete(trigger) {
|
|
1182
|
+
const current = await configGet();
|
|
1183
|
+
const res = await configDelete("");
|
|
1184
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1185
|
+
if (kept) saveSnapshot(current.data, trigger);
|
|
1186
|
+
if (!res.ok) {
|
|
1187
|
+
if (res.outcomeUnknown && kept) {
|
|
1188
|
+
return formatResult({
|
|
1189
|
+
...res,
|
|
1190
|
+
error: `${res.error}
|
|
1191
|
+
The pre-delete config was kept anyway, as snapshot [0] (trigger=${trigger}): if this delete did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it unloaded -- as long as this server can still reach Caddy's admin endpoint, which moves back to Caddy's default address when the deleted config set an admin.listen of its own. If the delete did not apply, that snapshot is simply the config read just before it was sent.`
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
return formatResult(res);
|
|
1195
|
+
}
|
|
1196
|
+
const note = priorConfigNote(kept, current, trigger);
|
|
1197
|
+
const listen = kept ? adminListenOf(current.data) : void 0;
|
|
1198
|
+
const adminNote = listen !== void 0 ? ` The unloaded config set admin.listen to "${listen}". If that was not already Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.` : current.ok ? "" : unreadablePriorAdminNote("unloaded");
|
|
1199
|
+
return {
|
|
1200
|
+
content: [
|
|
1201
|
+
{
|
|
1202
|
+
type: "text",
|
|
1203
|
+
text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1204
|
+
}
|
|
1205
|
+
]
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function isAtIdentifiedObject(subpath) {
|
|
1209
|
+
return /^\/*(\.\.\.\/*)?$/.test(subpath);
|
|
1079
1210
|
}
|
|
1080
1211
|
function registerConfigTools(server) {
|
|
1081
1212
|
server.tool(
|
|
@@ -1087,23 +1218,41 @@ function registerConfigTools(server) {
|
|
|
1087
1218
|
);
|
|
1088
1219
|
server.tool(
|
|
1089
1220
|
"caddy_config_set",
|
|
1090
|
-
"Write config at a JSON path. Mode 'overwrite' (default) replaces
|
|
1221
|
+
"Write config at a JSON path. Mode 'overwrite' (default) replaces the value at the path (PATCH) \u2014 idempotent, but it replaces the WHOLE subtree there: writing [] to '.../routes' drops every route. Mode 'append' (POST) adds to an array \u2014 NOT idempotent: calling twice with the same route duplicates it \u2014 but on a non-array key it REPLACES whatever is there, and it cannot create missing parent objects. Mode 'insert' (PUT) inserts at an array index (useful for route ordering), or strictly creates an object key together with any missing parents and fails with 409 if the key already exists \u2014 the safe way to create a server or app, including on an instance with no config at all. Any path that addresses the config ROOT ('', '/', 'config', '/config/', a slash-only variant, or any of those followed by a lone '...' segment) addresses the ENTIRE config and requires confirm=true: 'overwrite' and 'append' there REPLACE the whole configuration with `value` \u2014 Caddy runs it exactly as caddy_load would \u2014 so every app, server and route not in `value` is discarded, and so is the 'admin' block unless `value` carries one. Caddy then re-binds its admin endpoint to the new config's admin.listen, or to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment) when it sets none, or to no address at all when it sets admin.disabled; if CADDY_ADMIN_URL points anywhere else, neither this server nor caddy_revert can reach Caddy afterwards. A root write is snapshotted first, so caddy_revert can restore it while Caddy is still reachable; no other path is snapshotted. 'insert' at the root only succeeds after a root caddy_config_delete (Caddy answers 409 on any other instance, including one started with no config), and 'overwrite' is the reverse: after a root caddy_config_delete it answers 404 until 'append', 'insert' or caddy_load re-creates the config. To replace the whole config deliberately, caddy_load does the same thing behind the same gate and also accepts a Caddyfile.",
|
|
1091
1222
|
{
|
|
1092
1223
|
path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
1093
1224
|
value: z3.any().describe("The JSON value to set at the path"),
|
|
1094
1225
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
1095
1226
|
"'overwrite' = PATCH (replace existing, default, idempotent; 404 if the key does not exist), 'append' = POST (appends to arrays, NOT idempotent; REPLACES an existing non-array key; cannot create missing parents), 'insert' = PUT (inserts at an array index, or strictly creates an object key and any missing parents; 409 if the key exists)"
|
|
1227
|
+
),
|
|
1228
|
+
confirm: z3.boolean().optional().default(false).describe(
|
|
1229
|
+
"Must be true when `path` addresses the config root ('', '/', 'config', '/config/', slash-only variants, or a lone '...' segment): that write replaces the ENTIRE config (safety). Ignored for every other path."
|
|
1096
1230
|
)
|
|
1097
1231
|
},
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1232
|
+
// destructiveHint is TRUE because the hint covers the whole tool and a host
|
|
1233
|
+
// gates on it before it can see the path or the mode: the default mode is a
|
|
1234
|
+
// PATCH that replaces whatever subtree the path names -- a route list, a
|
|
1235
|
+
// server, all of `apps` -- and at the root it replaces the entire config
|
|
1236
|
+
// (below). MCP defines destructiveHint:false as "only additive updates".
|
|
1237
|
+
// Appending to an array or strictly creating a key is additive; the
|
|
1238
|
+
// default mode is not, and neither is 'append' on a non-array key, which
|
|
1239
|
+
// replaces it. idempotentHint stays false for 'append', which is a POST: a
|
|
1240
|
+
// repeated call appends a second copy.
|
|
1241
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1242
|
+
async ({ path, value, mode, confirm }) => {
|
|
1243
|
+
if (!isRootConfigPath(path)) return formatResult(await writeAt(mode, path, value));
|
|
1244
|
+
if (!confirm) {
|
|
1245
|
+
return rootWriteRefusal(
|
|
1246
|
+
`The path "${path}" addresses the config root.`,
|
|
1247
|
+
"pass its path instead (e.g. 'apps/http/servers/srv0')"
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
return rootConfigWrite(mode, value, "caddy_config_set");
|
|
1102
1251
|
}
|
|
1103
1252
|
);
|
|
1104
1253
|
server.tool(
|
|
1105
1254
|
"caddy_config_delete",
|
|
1106
|
-
"Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true. Any path that addresses the config ROOT ('', '/', 'config', '/config/',
|
|
1255
|
+
"Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true. Any path that addresses the config ROOT ('', '/', 'config', '/config/', slash-only variants of those, or any of those followed by a lone '...' segment) addresses the ENTIRE config and unloads it: every app and server goes, and so does the 'admin' block, after which Caddy re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment). If CADDY_ADMIN_URL points anywhere else, neither this server nor caddy_revert can reach Caddy afterwards. A root delete is snapshotted first, so caddy_revert can restore it while Caddy is still reachable; no other path is snapshotted. To REPLACE the config rather than unload it, use caddy_load.",
|
|
1107
1256
|
{
|
|
1108
1257
|
path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
|
|
1109
1258
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
|
|
@@ -1114,49 +1263,27 @@ function registerConfigTools(server) {
|
|
|
1114
1263
|
// array after a delete, so repeating that call removes a DIFFERENT route each
|
|
1115
1264
|
// time. caddy_remove_route carries the same correction for the byte-identical
|
|
1116
1265
|
// underlying request; the two must agree. Nothing here is auto-recoverable
|
|
1117
|
-
// either: apart from a root delete (below), only
|
|
1118
|
-
// snapshot,
|
|
1266
|
+
// either: apart from a root delete (below), only whole-config changes
|
|
1267
|
+
// capture a snapshot -- caddy_load, a caddy_revert apply, and a root
|
|
1268
|
+
// caddy_config_set -- so a spurious repeat cannot be undone with
|
|
1269
|
+
// caddy_revert.
|
|
1119
1270
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1120
1271
|
async ({ path, confirm }) => {
|
|
1121
1272
|
const root = isRootConfigPath(path);
|
|
1122
1273
|
if (!confirm) {
|
|
1274
|
+
if (root) return rootDeleteRefusal(`The path "${path}" addresses the config root.`);
|
|
1123
1275
|
return {
|
|
1124
1276
|
isError: true,
|
|
1125
1277
|
content: [
|
|
1126
1278
|
{
|
|
1127
1279
|
type: "text",
|
|
1128
|
-
text:
|
|
1280
|
+
text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
|
|
1129
1281
|
}
|
|
1130
1282
|
]
|
|
1131
1283
|
};
|
|
1132
1284
|
}
|
|
1133
1285
|
if (!root) return formatResult(await configDelete(path));
|
|
1134
|
-
|
|
1135
|
-
const res = await configDelete("");
|
|
1136
|
-
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1137
|
-
if (kept) saveSnapshot(current.data, "caddy_config_delete");
|
|
1138
|
-
if (!res.ok) {
|
|
1139
|
-
if (res.outcomeUnknown && kept) {
|
|
1140
|
-
return formatResult({
|
|
1141
|
-
...res,
|
|
1142
|
-
error: `${res.error}
|
|
1143
|
-
The pre-delete config was kept anyway, as snapshot [0] (trigger=caddy_config_delete): if this delete did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it unloaded -- as long as this server can still reach Caddy's admin endpoint, which moves back to Caddy's default address when the deleted config set an admin.listen of its own. If the delete did not apply, that snapshot is simply the config read just before it was sent.`
|
|
1144
|
-
});
|
|
1145
|
-
}
|
|
1146
|
-
return formatResult(res);
|
|
1147
|
-
}
|
|
1148
|
-
const note = kept ? ` The prior config was saved as snapshot [0] (trigger=caddy_config_delete): caddy_revert { action: "apply", index: 0, confirm: true } restores it.` : current.ok ? " Warning: the prior config was empty or not a JSON object, so no snapshot was captured -- there was nothing to restore." : " Warning: the prior config could not be read, so no snapshot was captured and this unload cannot be reverted.";
|
|
1149
|
-
const adm = kept ? current.data.admin : void 0;
|
|
1150
|
-
const listen = adm !== null && typeof adm === "object" && !Array.isArray(adm) ? adm.listen : void 0;
|
|
1151
|
-
const adminNote = typeof listen === "string" && listen !== "" ? ` The unloaded config set admin.listen to "${listen}". If that was not already Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.` : "";
|
|
1152
|
-
return {
|
|
1153
|
-
content: [
|
|
1154
|
-
{
|
|
1155
|
-
type: "text",
|
|
1156
|
-
text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1157
|
-
}
|
|
1158
|
-
]
|
|
1159
|
-
};
|
|
1286
|
+
return rootConfigDelete("caddy_config_delete");
|
|
1160
1287
|
}
|
|
1161
1288
|
);
|
|
1162
1289
|
server.tool(
|
|
@@ -1197,7 +1324,7 @@ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if th
|
|
|
1197
1324
|
);
|
|
1198
1325
|
server.tool(
|
|
1199
1326
|
"caddy_revert",
|
|
1200
|
-
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete that
|
|
1327
|
+
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete or caddy_config_set at the config root (any path that addresses the whole config), and before a caddy_config_by_id set or delete whose @id resolves to the root with no subpath (or a lone '...'); no other delete or set is snapshotted. Last 10. By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
|
|
1201
1328
|
{
|
|
1202
1329
|
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
1203
1330
|
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
@@ -1289,7 +1416,7 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
|
|
|
1289
1416
|
);
|
|
1290
1417
|
server.tool(
|
|
1291
1418
|
"caddy_config_by_id",
|
|
1292
|
-
"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. The 'delete' action requires confirm=true.",
|
|
1419
|
+
"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. The 'delete' action requires confirm=true. Every 'set' and 'delete' first asks Caddy where `id` resolves, and refuses an id that does not resolve inside the config tree (including an unknown one). If Caddy resolves `id` to the config ROOT -- normally because it is the config's own top-level '@id', string or number -- it names the ENTIRE config: 'set' and 'delete' with no subpath (or a lone '...') then act on the whole configuration exactly as caddy_config_set and caddy_config_delete do at the config root -- 'set' replaces it and 'delete' unloads it, the 'admin' block included, so Caddy's admin endpoint can move and CADDY_ADMIN_URL stop reaching it. Both then require confirm=true, snapshot the prior config first so caddy_revert can restore it, and report where the admin endpoint went. A subpath inside it (e.g. 'apps/http') is an ordinary write.",
|
|
1293
1420
|
{
|
|
1294
1421
|
id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
|
|
1295
1422
|
action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
@@ -1298,7 +1425,9 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
|
|
|
1298
1425
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
1299
1426
|
"For 'set' action: 'overwrite' = PATCH (replace the identified object, or the value at subpath; default). 'append' = POST and 'insert' = PUT behave as in caddy_config_set at the resolved path: with a subpath into an array, POST appends and PUT inserts at the index; PUT also strictly creates an object key (409 if it exists). With NO subpath: for an array element (a route) neither replaces it \u2014 POST adds the value as a new element at the end of that array, PUT inserts it just before the identified one, and both are rejected with 'duplicate ID' if the value carries the same @id; for an object held under a key (a server) POST REPLACES it wholesale and PUT fails with 409. Use 'overwrite' to replace in place."
|
|
1300
1427
|
),
|
|
1301
|
-
confirm: z3.boolean().optional().default(false).describe(
|
|
1428
|
+
confirm: z3.boolean().optional().default(false).describe(
|
|
1429
|
+
"Must be true to actually delete, and to 'set' when Caddy resolves `id` to the config root (that replaces the ENTIRE config). Ignored for every other 'set'."
|
|
1430
|
+
)
|
|
1302
1431
|
},
|
|
1303
1432
|
// destructiveHint is keyed to the worst thing this tool can do, not the
|
|
1304
1433
|
// default action: action='delete' removes the identified object and every
|
|
@@ -1311,13 +1440,33 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
|
|
|
1311
1440
|
if (action === "get") {
|
|
1312
1441
|
return formatResult(await configByIdGet(id, subpath));
|
|
1313
1442
|
}
|
|
1314
|
-
if (action === "set") {
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1443
|
+
if (action === "set" && value === void 0) {
|
|
1444
|
+
return {
|
|
1445
|
+
isError: true,
|
|
1446
|
+
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
const probe = await configByIdGet(id, "@id");
|
|
1450
|
+
if (!probe.ok) {
|
|
1451
|
+
return formatResult({
|
|
1452
|
+
...probe,
|
|
1453
|
+
error: `Could not check where Caddy resolves @id "${id}", so nothing was changed.
|
|
1454
|
+
${probe.error}`
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
const objectPath = idObjectPath(probe.etag);
|
|
1458
|
+
if (objectPath === void 0 || !(objectPath === "/config" || objectPath.startsWith("/config/"))) {
|
|
1459
|
+
const why = !probe.etag ? `Caddy's answer to a read of @id "${id}" carried no ETag header, so this tool cannot tell where Caddy resolves that id or what a write through it would change. Caddy sends that header from 2.8.0 on; earlier versions send it as a trailer or not at all, and a proxy in front of Caddy can strip it.` : objectPath !== void 0 ? `Caddy resolves @id "${id}" to ${objectPath || "/"}, which is outside the config tree, so this tool will not write through it.` : `Caddy's answer to a read of @id "${id}" carried an ETag this tool could not read a config path from, so it cannot tell what a write through that id would change.`;
|
|
1460
|
+
return { isError: true, content: [{ type: "text", text: `${why} Nothing was changed.` }] };
|
|
1461
|
+
}
|
|
1462
|
+
if (objectPath === "/config" && isAtIdentifiedObject(subpath)) {
|
|
1463
|
+
const target = `Caddy resolves @id "${id}" to the config root.`;
|
|
1464
|
+
if (action === "set") {
|
|
1465
|
+
return confirm ? rootConfigWrite(mode, value, "caddy_config_by_id") : rootWriteRefusal(target, "pass a subpath inside it instead (e.g. subpath 'apps/http')");
|
|
1320
1466
|
}
|
|
1467
|
+
return confirm ? rootConfigDelete("caddy_config_by_id") : rootDeleteRefusal(target);
|
|
1468
|
+
}
|
|
1469
|
+
if (action === "set") {
|
|
1321
1470
|
const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
|
|
1322
1471
|
return formatResult(await configByIdSet(id, value, method, subpath));
|
|
1323
1472
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.6",
|
|
4
4
|
"mcpName": "io.github.YawLabs/caddy-mcp",
|
|
5
5
|
"description": "Caddy MCP server for Claude Code, Cursor, and any MCP client: admin API, config, routes, reverse proxy, TLS, PKI, metrics",
|
|
6
6
|
"license": "MIT",
|