@yawlabs/caddy-mcp 2.5.5 → 2.5.7

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 CHANGED
@@ -123,9 +123,9 @@ Use the same JSON block shown above in any of these.
123
123
  - **caddy_config_get** — Read config at any JSON path (or the full config).
124
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
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`.
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` or `caddy_config_set` at 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.
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, but the `If-Match` (ETag) concurrency guard needs Caddy
288
- 2.5.2 or later — before that Caddy ignores the header silently. The `@id` write
289
- path relies on `PATCH` semantics that the live integration suite pins per release.
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 (760 unit tests, +39 POSIX-only unix-socket and launcher tests; +37 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
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
 
package/dist/api.d.ts CHANGED
@@ -86,6 +86,29 @@ export declare function stop(): Promise<ApiResponse>;
86
86
  export declare function getUpstreams(): Promise<ApiResponse>;
87
87
  export declare function getPki(ca?: string): Promise<ApiResponse>;
88
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;
89
112
  export declare function configByIdGet<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
90
113
  export declare function configByIdSet<T = any>(id: string, value: unknown, method?: "POST" | "PATCH" | "PUT", subpath?: string): Promise<ApiResponse<T>>;
91
114
  export declare function configByIdDelete<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
package/dist/index.js CHANGED
@@ -557,6 +557,16 @@ function idPath(id, subpath) {
557
557
  const encodedId = encodePathSegments(id);
558
558
  return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
559
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
+ }
560
570
  function configByIdGet(id, subpath = "") {
561
571
  const badId = rejectTraversal(id);
562
572
  if (badId) return Promise.resolve(badId);
@@ -1116,6 +1126,90 @@ function rootWriteAdminNote(current, value) {
1116
1126
  function unreadablePriorAdminNote(verb) {
1117
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.`;
1118
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);
1212
+ }
1119
1213
  function registerConfigTools(server) {
1120
1214
  server.tool(
1121
1215
  "caddy_config_get",
@@ -1148,46 +1242,14 @@ function registerConfigTools(server) {
1148
1242
  // repeated call appends a second copy.
1149
1243
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1150
1244
  async ({ path, value, mode, confirm }) => {
1151
- const write = (p) => mode === "overwrite" ? configPatch(p, value) : mode === "insert" ? configPut(p, value) : configPost(p, value);
1152
- if (!isRootConfigPath(path)) return formatResult(await write(path));
1245
+ if (!isRootConfigPath(path)) return formatResult(await writeAt(mode, path, value));
1153
1246
  if (!confirm) {
1154
- return {
1155
- isError: true,
1156
- content: [
1157
- {
1158
- type: "text",
1159
- text: `Refusing to write the ENTIRE config without confirm=true. The path "${path}" addresses the config root, so 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, pass its path instead (e.g. 'apps/http/servers/srv0'); to replace all of it deliberately, caddy_load does the same behind the same gate. Re-run with confirm:true to proceed.`
1160
- }
1161
- ]
1162
- };
1163
- }
1164
- const current = await configGet();
1165
- const res = await write("");
1166
- const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1167
- if (kept) saveSnapshot(current.data, "caddy_config_set");
1168
- const adminNote = rootWriteAdminNote(current, value);
1169
- if (!res.ok) {
1170
- if (res.outcomeUnknown && (kept || adminNote)) {
1171
- const snapshotPart = kept ? `The pre-write config was kept anyway, as snapshot [0] (trigger=caddy_config_set): 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.` : "";
1172
- const adminPart = adminNote ? `If the write applied:${adminNote}` : "";
1173
- const tail = kept ? "If it did not apply, that snapshot is simply the config read just before it was sent." : "";
1174
- return formatResult({
1175
- ...res,
1176
- error: `${res.error}
1177
- ${[snapshotPart, adminPart, tail].filter(Boolean).join(" ")}`
1178
- });
1179
- }
1180
- return formatResult(res);
1247
+ return rootWriteRefusal(
1248
+ `The path "${path}" addresses the config root.`,
1249
+ "pass its path instead (e.g. 'apps/http/servers/srv0')"
1250
+ );
1181
1251
  }
1182
- const note = priorConfigNote(kept, current, "caddy_config_set");
1183
- return {
1184
- content: [
1185
- {
1186
- type: "text",
1187
- text: `Replaced the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
1188
- }
1189
- ]
1190
- };
1252
+ return rootConfigWrite(mode, value, "caddy_config_set");
1191
1253
  }
1192
1254
  );
1193
1255
  server.tool(
@@ -1211,42 +1273,19 @@ ${[snapshotPart, adminPart, tail].filter(Boolean).join(" ")}`
1211
1273
  async ({ path, confirm }) => {
1212
1274
  const root = isRootConfigPath(path);
1213
1275
  if (!confirm) {
1276
+ if (root) return rootDeleteRefusal(`The path "${path}" addresses the config root.`);
1214
1277
  return {
1215
1278
  isError: true,
1216
1279
  content: [
1217
1280
  {
1218
1281
  type: "text",
1219
- text: root ? `Refusing to delete the ENTIRE config without confirm=true. The path "${path}" addresses the config root, so 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.` : `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
1282
+ text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
1220
1283
  }
1221
1284
  ]
1222
1285
  };
1223
1286
  }
1224
1287
  if (!root) return formatResult(await configDelete(path));
1225
- const current = await configGet();
1226
- const res = await configDelete("");
1227
- const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1228
- if (kept) saveSnapshot(current.data, "caddy_config_delete");
1229
- if (!res.ok) {
1230
- if (res.outcomeUnknown && kept) {
1231
- return formatResult({
1232
- ...res,
1233
- error: `${res.error}
1234
- 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.`
1235
- });
1236
- }
1237
- return formatResult(res);
1238
- }
1239
- const note = priorConfigNote(kept, current, "caddy_config_delete");
1240
- const listen = kept ? adminListenOf(current.data) : void 0;
1241
- 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");
1242
- return {
1243
- content: [
1244
- {
1245
- type: "text",
1246
- text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
1247
- }
1248
- ]
1249
- };
1288
+ return rootConfigDelete("caddy_config_delete");
1250
1289
  }
1251
1290
  );
1252
1291
  server.tool(
@@ -1287,7 +1326,7 @@ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if th
1287
1326
  );
1288
1327
  server.tool(
1289
1328
  "caddy_revert",
1290
- "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); 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).",
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).",
1291
1330
  {
1292
1331
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
1293
1332
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -1379,7 +1418,7 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
1379
1418
  );
1380
1419
  server.tool(
1381
1420
  "caddy_config_by_id",
1382
- "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.",
1383
1422
  {
1384
1423
  id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
1385
1424
  action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
@@ -1388,7 +1427,9 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
1388
1427
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
1389
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."
1390
1429
  ),
1391
- confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
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
+ )
1392
1433
  },
1393
1434
  // destructiveHint is keyed to the worst thing this tool can do, not the
1394
1435
  // default action: action='delete' removes the identified object and every
@@ -1401,13 +1442,33 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
1401
1442
  if (action === "get") {
1402
1443
  return formatResult(await configByIdGet(id, subpath));
1403
1444
  }
1404
- if (action === "set") {
1405
- if (value === void 0) {
1406
- return {
1407
- isError: true,
1408
- content: [{ type: "text", text: "Error: value is required for 'set' action" }]
1409
- };
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')");
1410
1468
  }
1469
+ return confirm ? rootConfigDelete("caddy_config_by_id") : rootDeleteRefusal(target);
1470
+ }
1471
+ if (action === "set") {
1411
1472
  const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
1412
1473
  return formatResult(await configByIdSet(id, value, method, subpath));
1413
1474
  }
package/dist/server.js CHANGED
@@ -555,6 +555,16 @@ function idPath(id, subpath) {
555
555
  const encodedId = encodePathSegments(id);
556
556
  return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
557
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
+ }
558
568
  function configByIdGet(id, subpath = "") {
559
569
  const badId = rejectTraversal(id);
560
570
  if (badId) return Promise.resolve(badId);
@@ -1114,6 +1124,90 @@ function rootWriteAdminNote(current, value) {
1114
1124
  function unreadablePriorAdminNote(verb) {
1115
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.`;
1116
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);
1210
+ }
1117
1211
  function registerConfigTools(server) {
1118
1212
  server.tool(
1119
1213
  "caddy_config_get",
@@ -1146,46 +1240,14 @@ function registerConfigTools(server) {
1146
1240
  // repeated call appends a second copy.
1147
1241
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1148
1242
  async ({ path, value, mode, confirm }) => {
1149
- const write = (p) => mode === "overwrite" ? configPatch(p, value) : mode === "insert" ? configPut(p, value) : configPost(p, value);
1150
- if (!isRootConfigPath(path)) return formatResult(await write(path));
1243
+ if (!isRootConfigPath(path)) return formatResult(await writeAt(mode, path, value));
1151
1244
  if (!confirm) {
1152
- return {
1153
- isError: true,
1154
- content: [
1155
- {
1156
- type: "text",
1157
- text: `Refusing to write the ENTIRE config without confirm=true. The path "${path}" addresses the config root, so 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, pass its path instead (e.g. 'apps/http/servers/srv0'); to replace all of it deliberately, caddy_load does the same behind the same gate. Re-run with confirm:true to proceed.`
1158
- }
1159
- ]
1160
- };
1161
- }
1162
- const current = await configGet();
1163
- const res = await write("");
1164
- const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1165
- if (kept) saveSnapshot(current.data, "caddy_config_set");
1166
- const adminNote = rootWriteAdminNote(current, value);
1167
- if (!res.ok) {
1168
- if (res.outcomeUnknown && (kept || adminNote)) {
1169
- const snapshotPart = kept ? `The pre-write config was kept anyway, as snapshot [0] (trigger=caddy_config_set): 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.` : "";
1170
- const adminPart = adminNote ? `If the write applied:${adminNote}` : "";
1171
- const tail = kept ? "If it did not apply, that snapshot is simply the config read just before it was sent." : "";
1172
- return formatResult({
1173
- ...res,
1174
- error: `${res.error}
1175
- ${[snapshotPart, adminPart, tail].filter(Boolean).join(" ")}`
1176
- });
1177
- }
1178
- return formatResult(res);
1245
+ return rootWriteRefusal(
1246
+ `The path "${path}" addresses the config root.`,
1247
+ "pass its path instead (e.g. 'apps/http/servers/srv0')"
1248
+ );
1179
1249
  }
1180
- const note = priorConfigNote(kept, current, "caddy_config_set");
1181
- return {
1182
- content: [
1183
- {
1184
- type: "text",
1185
- text: `Replaced the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
1186
- }
1187
- ]
1188
- };
1250
+ return rootConfigWrite(mode, value, "caddy_config_set");
1189
1251
  }
1190
1252
  );
1191
1253
  server.tool(
@@ -1209,42 +1271,19 @@ ${[snapshotPart, adminPart, tail].filter(Boolean).join(" ")}`
1209
1271
  async ({ path, confirm }) => {
1210
1272
  const root = isRootConfigPath(path);
1211
1273
  if (!confirm) {
1274
+ if (root) return rootDeleteRefusal(`The path "${path}" addresses the config root.`);
1212
1275
  return {
1213
1276
  isError: true,
1214
1277
  content: [
1215
1278
  {
1216
1279
  type: "text",
1217
- text: root ? `Refusing to delete the ENTIRE config without confirm=true. The path "${path}" addresses the config root, so 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.` : `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
1280
+ text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
1218
1281
  }
1219
1282
  ]
1220
1283
  };
1221
1284
  }
1222
1285
  if (!root) return formatResult(await configDelete(path));
1223
- const current = await configGet();
1224
- const res = await configDelete("");
1225
- const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
1226
- if (kept) saveSnapshot(current.data, "caddy_config_delete");
1227
- if (!res.ok) {
1228
- if (res.outcomeUnknown && kept) {
1229
- return formatResult({
1230
- ...res,
1231
- error: `${res.error}
1232
- 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.`
1233
- });
1234
- }
1235
- return formatResult(res);
1236
- }
1237
- const note = priorConfigNote(kept, current, "caddy_config_delete");
1238
- const listen = kept ? adminListenOf(current.data) : void 0;
1239
- 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");
1240
- return {
1241
- content: [
1242
- {
1243
- type: "text",
1244
- text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
1245
- }
1246
- ]
1247
- };
1286
+ return rootConfigDelete("caddy_config_delete");
1248
1287
  }
1249
1288
  );
1250
1289
  server.tool(
@@ -1285,7 +1324,7 @@ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if th
1285
1324
  );
1286
1325
  server.tool(
1287
1326
  "caddy_revert",
1288
- "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); 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).",
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).",
1289
1328
  {
1290
1329
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
1291
1330
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -1377,7 +1416,7 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
1377
1416
  );
1378
1417
  server.tool(
1379
1418
  "caddy_config_by_id",
1380
- "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.",
1381
1420
  {
1382
1421
  id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
1383
1422
  action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
@@ -1386,7 +1425,9 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
1386
1425
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
1387
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."
1388
1427
  ),
1389
- confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
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
+ )
1390
1431
  },
1391
1432
  // destructiveHint is keyed to the worst thing this tool can do, not the
1392
1433
  // default action: action='delete' removes the identified object and every
@@ -1399,13 +1440,33 @@ The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), s
1399
1440
  if (action === "get") {
1400
1441
  return formatResult(await configByIdGet(id, subpath));
1401
1442
  }
1402
- if (action === "set") {
1403
- if (value === void 0) {
1404
- return {
1405
- isError: true,
1406
- content: [{ type: "text", text: "Error: value is required for 'set' action" }]
1407
- };
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')");
1408
1466
  }
1467
+ return confirm ? rootConfigDelete("caddy_config_by_id") : rootDeleteRefusal(target);
1468
+ }
1469
+ if (action === "set") {
1409
1470
  const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
1410
1471
  return formatResult(await configByIdSet(id, value, method, subpath));
1411
1472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
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",