@yawlabs/caddy-mcp 2.5.4 → 2.5.5

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
@@ -1,5 +1,9 @@
1
1
  # @yawlabs/caddy-mcp
2
2
 
3
+ [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](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
  [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
9
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/caddy-mcp)](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
- [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](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). An empty path (`''`, `'/'`, `'config'`, `'/config/'`) 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`.
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
126
  - **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths. The `delete` action requires `confirm=true`.
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` 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` 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.
129
129
 
130
130
  ### Route operations (4)
131
131
 
@@ -297,7 +297,7 @@ npm install
297
297
  npm run lint # Biome check
298
298
  npm run lint:fix # Auto-fix
299
299
  npm run build # tsup bundle
300
- npm test # Vitest (692 unit tests, +39 POSIX-only unix-socket and launcher tests; +33 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
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)
301
301
  npm run typecheck # tsc --noEmit
302
302
  ```
303
303
 
@@ -307,4 +307,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release
307
307
 
308
308
  MIT
309
309
 
310
- [![Follow @TokenLimitNews on X](https://img.shields.io/badge/follow-%40TokenLimitNews-000000?logo=x&logoColor=white)](https://x.com/TokenLimitNews)
310
+ [![Follow @YawLabs on X](https://img.shields.io/badge/follow-%40YawLabs-000000?logo=x&logoColor=white)](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
  *
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 (/(^|\/)\.\.(\/|$)/.test(path)) {
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;
@@ -1076,8 +1079,42 @@ function getSnapshot(index) {
1076
1079
  }
1077
1080
 
1078
1081
  // src/tools/config.ts
1079
- function isRootConfigPath(path) {
1080
- return /^\/*$/.test(path.replace(/^\/?(config(\/|$))?/, ""));
1082
+ function adminListenOf(config) {
1083
+ if (!isSnapshotableConfig(config)) return void 0;
1084
+ const adm = config.admin;
1085
+ if (adm === null || typeof adm !== "object" || Array.isArray(adm)) return void 0;
1086
+ const listen = adm.listen;
1087
+ return typeof listen === "string" && listen !== "" ? listen : void 0;
1088
+ }
1089
+ function adminDisabledIn(config) {
1090
+ if (!isSnapshotableConfig(config)) return false;
1091
+ const adm = config.admin;
1092
+ return adm !== null && typeof adm === "object" && !Array.isArray(adm) && adm.disabled === true;
1093
+ }
1094
+ function priorConfigNote(kept, current, trigger) {
1095
+ if (kept) {
1096
+ return ` The prior config was saved as snapshot [0] (trigger=${trigger}): caddy_revert { action: "apply", index: 0, confirm: true } restores it.`;
1097
+ }
1098
+ 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.";
1099
+ }
1100
+ function rootWriteAdminNote(current, value) {
1101
+ if (adminDisabledIn(value)) {
1102
+ 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.";
1103
+ }
1104
+ const adminUrl = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
1105
+ const before = current.ok ? adminListenOf(current.data) : void 0;
1106
+ const after = adminListenOf(value);
1107
+ if (before !== void 0 && after !== before) {
1108
+ 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.`;
1109
+ }
1110
+ if (before === void 0 && after !== void 0) {
1111
+ 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.`;
1112
+ }
1113
+ if (!current.ok) return unreadablePriorAdminNote("replaced");
1114
+ return "";
1115
+ }
1116
+ function unreadablePriorAdminNote(verb) {
1117
+ 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.`;
1081
1118
  }
1082
1119
  function registerConfigTools(server) {
1083
1120
  server.tool(
@@ -1089,23 +1126,73 @@ function registerConfigTools(server) {
1089
1126
  );
1090
1127
  server.tool(
1091
1128
  "caddy_config_set",
1092
- "Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. 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.",
1129
+ "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
1130
  {
1094
1131
  path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
1095
1132
  value: z3.any().describe("The JSON value to set at the path"),
1096
1133
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
1097
1134
  "'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)"
1135
+ ),
1136
+ confirm: z3.boolean().optional().default(false).describe(
1137
+ "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
1138
  )
1099
1139
  },
1100
- { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1101
- async ({ path, value, mode }) => {
1102
- const res = mode === "overwrite" ? await configPatch(path, value) : mode === "insert" ? await configPut(path, value) : await configPost(path, value);
1103
- return formatResult(res);
1140
+ // destructiveHint is TRUE because the hint covers the whole tool and a host
1141
+ // gates on it before it can see the path or the mode: the default mode is a
1142
+ // PATCH that replaces whatever subtree the path names -- a route list, a
1143
+ // server, all of `apps` -- and at the root it replaces the entire config
1144
+ // (below). MCP defines destructiveHint:false as "only additive updates".
1145
+ // Appending to an array or strictly creating a key is additive; the
1146
+ // default mode is not, and neither is 'append' on a non-array key, which
1147
+ // replaces it. idempotentHint stays false for 'append', which is a POST: a
1148
+ // repeated call appends a second copy.
1149
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1150
+ 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));
1153
+ 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);
1181
+ }
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
+ };
1104
1191
  }
1105
1192
  );
1106
1193
  server.tool(
1107
1194
  "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/', and slash-only variants of those) 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.",
1195
+ "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
1196
  {
1110
1197
  path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
1111
1198
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
@@ -1116,8 +1203,10 @@ function registerConfigTools(server) {
1116
1203
  // array after a delete, so repeating that call removes a DIFFERENT route each
1117
1204
  // time. caddy_remove_route carries the same correction for the byte-identical
1118
1205
  // underlying request; the two must agree. Nothing here is auto-recoverable
1119
- // either: apart from a root delete (below), only caddy_load captures a
1120
- // snapshot, so a spurious repeat cannot be undone with caddy_revert.
1206
+ // either: apart from a root delete (below), only whole-config changes
1207
+ // capture a snapshot -- caddy_load, a caddy_revert apply, and a root
1208
+ // caddy_config_set -- so a spurious repeat cannot be undone with
1209
+ // caddy_revert.
1121
1210
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1122
1211
  async ({ path, confirm }) => {
1123
1212
  const root = isRootConfigPath(path);
@@ -1147,10 +1236,9 @@ The pre-delete config was kept anyway, as snapshot [0] (trigger=caddy_config_del
1147
1236
  }
1148
1237
  return formatResult(res);
1149
1238
  }
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.` : "";
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");
1154
1242
  return {
1155
1243
  content: [
1156
1244
  {
@@ -1199,7 +1287,7 @@ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if th
1199
1287
  );
1200
1288
  server.tool(
1201
1289
  "caddy_revert",
1202
- "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete that unloads the whole config (an empty path); no other delete 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).",
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).",
1203
1291
  {
1204
1292
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
1205
1293
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
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 (/(^|\/)\.\.(\/|$)/.test(path)) {
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;
@@ -1074,8 +1077,42 @@ function getSnapshot(index) {
1074
1077
  }
1075
1078
 
1076
1079
  // src/tools/config.ts
1077
- function isRootConfigPath(path) {
1078
- return /^\/*$/.test(path.replace(/^\/?(config(\/|$))?/, ""));
1080
+ function adminListenOf(config) {
1081
+ if (!isSnapshotableConfig(config)) return void 0;
1082
+ const adm = config.admin;
1083
+ if (adm === null || typeof adm !== "object" || Array.isArray(adm)) return void 0;
1084
+ const listen = adm.listen;
1085
+ return typeof listen === "string" && listen !== "" ? listen : void 0;
1086
+ }
1087
+ function adminDisabledIn(config) {
1088
+ if (!isSnapshotableConfig(config)) return false;
1089
+ const adm = config.admin;
1090
+ return adm !== null && typeof adm === "object" && !Array.isArray(adm) && adm.disabled === true;
1091
+ }
1092
+ function priorConfigNote(kept, current, trigger) {
1093
+ if (kept) {
1094
+ return ` The prior config was saved as snapshot [0] (trigger=${trigger}): caddy_revert { action: "apply", index: 0, confirm: true } restores it.`;
1095
+ }
1096
+ 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.";
1097
+ }
1098
+ function rootWriteAdminNote(current, value) {
1099
+ if (adminDisabledIn(value)) {
1100
+ 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.";
1101
+ }
1102
+ const adminUrl = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
1103
+ const before = current.ok ? adminListenOf(current.data) : void 0;
1104
+ const after = adminListenOf(value);
1105
+ if (before !== void 0 && after !== before) {
1106
+ 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.`;
1107
+ }
1108
+ if (before === void 0 && after !== void 0) {
1109
+ 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.`;
1110
+ }
1111
+ if (!current.ok) return unreadablePriorAdminNote("replaced");
1112
+ return "";
1113
+ }
1114
+ function unreadablePriorAdminNote(verb) {
1115
+ 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.`;
1079
1116
  }
1080
1117
  function registerConfigTools(server) {
1081
1118
  server.tool(
@@ -1087,23 +1124,73 @@ function registerConfigTools(server) {
1087
1124
  );
1088
1125
  server.tool(
1089
1126
  "caddy_config_set",
1090
- "Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. 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.",
1127
+ "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
1128
  {
1092
1129
  path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
1093
1130
  value: z3.any().describe("The JSON value to set at the path"),
1094
1131
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
1095
1132
  "'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)"
1133
+ ),
1134
+ confirm: z3.boolean().optional().default(false).describe(
1135
+ "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
1136
  )
1097
1137
  },
1098
- { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1099
- async ({ path, value, mode }) => {
1100
- const res = mode === "overwrite" ? await configPatch(path, value) : mode === "insert" ? await configPut(path, value) : await configPost(path, value);
1101
- return formatResult(res);
1138
+ // destructiveHint is TRUE because the hint covers the whole tool and a host
1139
+ // gates on it before it can see the path or the mode: the default mode is a
1140
+ // PATCH that replaces whatever subtree the path names -- a route list, a
1141
+ // server, all of `apps` -- and at the root it replaces the entire config
1142
+ // (below). MCP defines destructiveHint:false as "only additive updates".
1143
+ // Appending to an array or strictly creating a key is additive; the
1144
+ // default mode is not, and neither is 'append' on a non-array key, which
1145
+ // replaces it. idempotentHint stays false for 'append', which is a POST: a
1146
+ // repeated call appends a second copy.
1147
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1148
+ 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));
1151
+ 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);
1179
+ }
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
+ };
1102
1189
  }
1103
1190
  );
1104
1191
  server.tool(
1105
1192
  "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/', and slash-only variants of those) 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.",
1193
+ "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
1194
  {
1108
1195
  path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
1109
1196
  confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
@@ -1114,8 +1201,10 @@ function registerConfigTools(server) {
1114
1201
  // array after a delete, so repeating that call removes a DIFFERENT route each
1115
1202
  // time. caddy_remove_route carries the same correction for the byte-identical
1116
1203
  // underlying request; the two must agree. Nothing here is auto-recoverable
1117
- // either: apart from a root delete (below), only caddy_load captures a
1118
- // snapshot, so a spurious repeat cannot be undone with caddy_revert.
1204
+ // either: apart from a root delete (below), only whole-config changes
1205
+ // capture a snapshot -- caddy_load, a caddy_revert apply, and a root
1206
+ // caddy_config_set -- so a spurious repeat cannot be undone with
1207
+ // caddy_revert.
1119
1208
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1120
1209
  async ({ path, confirm }) => {
1121
1210
  const root = isRootConfigPath(path);
@@ -1145,10 +1234,9 @@ The pre-delete config was kept anyway, as snapshot [0] (trigger=caddy_config_del
1145
1234
  }
1146
1235
  return formatResult(res);
1147
1236
  }
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.` : "";
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");
1152
1240
  return {
1153
1241
  content: [
1154
1242
  {
@@ -1197,7 +1285,7 @@ The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if th
1197
1285
  );
1198
1286
  server.tool(
1199
1287
  "caddy_revert",
1200
- "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete that unloads the whole config (an empty path); no other delete 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).",
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).",
1201
1289
  {
1202
1290
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
1203
1291
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.5.4",
3
+ "version": "2.5.5",
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",