@yawlabs/caddy-mcp 1.2.9 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,245 +1,246 @@
1
- # @yawlabs/caddy-mcp
2
-
3
- [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
- [![GitHub stars](https://img.shields.io/github/stars/YawLabs/caddy-mcp)](https://github.com/YawLabs/caddy-mcp/stargazers)
6
-
7
- **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 18 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics, snapshots.
8
-
9
- Built and maintained by [Yaw Labs](https://yaw.sh).
10
-
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
- ## Why this one?
16
-
17
- Other Caddy MCP servers wrap half the admin API and silently swallow errors. This one doesn't.
18
-
19
- - **Complete admin API coverage** — every documented endpoint: `/load`, `/config/*`, `/id/*`, `/stop`, `/adapt`, `/pki/ca/*`, `/reverse_proxy/upstreams`, `/metrics`. No placeholder tools that 404.
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.
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
- - **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
- - **Fallback error surfacing** — when a TLS write PATCH fails and the POST fallback also fails, both error bodies are returned so you know what actually went wrong.
25
- - **Tool annotations** — every tool declares `readOnlyHint`, `destructiveHint`, and `idempotentHint`, so MCP clients can skip confirmations for safe ops.
26
- - **Instant startup** — ships as a single bundle with two runtime deps (the MCP SDK + Zod). No 5-minute `node_modules` install.
27
- - **Input hardening** — adapter names, `@id` values, server names, and CA ids are all regex-validated with length caps. Blocks CRLF header injection and ReDoS.
28
-
29
- ## Quick start
30
-
31
- **1. Enable the Caddy admin API**
32
-
33
- Caddy ships with the admin API enabled on `localhost:2019` by default. If you're running Caddy in Docker or on a remote host, expose it via `CADDY_ADMIN_URL`.
34
-
35
- **2. Create `.mcp.json` in your project root**
36
-
37
- macOS / Linux / WSL:
38
-
39
- ```json
40
- {
41
- "mcpServers": {
42
- "caddy": {
43
- "command": "npx",
44
- "args": ["-y", "@yawlabs/caddy-mcp@latest"]
45
- }
46
- }
47
- }
48
- ```
49
-
50
- Windows:
51
-
52
- ```json
53
- {
54
- "mcpServers": {
55
- "caddy": {
56
- "command": "cmd",
57
- "args": ["/c", "npx", "-y", "@yawlabs/caddy-mcp@latest"]
58
- }
59
- }
60
- }
61
- ```
62
-
63
- > **Why the extra step on Windows?** Since Node 20, `child_process.spawn` cannot directly execute `.cmd` files (that's what `npx` is on Windows). Wrapping with `cmd /c` is the standard workaround. This file is safe to commit — it contains no secrets.
64
-
65
- **3. Restart and approve**
66
-
67
- Restart Claude Code (or your MCP client) and approve the Caddy MCP server when prompted.
68
-
69
- That's it. Now ask your AI assistant:
70
-
71
- > "Proxy api.local to localhost:3000"
72
- >
73
- > "What routes are configured on srv0?"
74
- >
75
- > "Show me the Prometheus metrics"
76
-
77
- ## Configuration
78
-
79
- | Environment variable | Default | Description |
80
- |---|---|---|
81
- | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
82
- | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
83
- | `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures (5xx, network errors). 4xx and 412 never retry. POSTs to `/config/*` and `/id/*` also skip retry (non-idempotent appends/creates -- retrying could duplicate routes or 409 a half-applied create). POSTs to `/load`, `/adapt`, `/stop` still retry. Hard-capped at 5; values above the cap log a one-time stderr notice so the clamp is visible. Set to `0` to disable. |
84
- | `CADDY_LOAD_TIMEOUT` | `60000` | Timeout in ms for the `/load` endpoint; raise for ACME-heavy bring-ups where provisioning many certificates can exceed the default. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
85
-
86
- **Alternate MCP clients:**
87
-
88
- | Client | Config file |
89
- |---|---|
90
- | Claude Code | `.mcp.json` (project root) or `~/.claude.json` (global) |
91
- | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
92
- | Cursor | `~/.cursor/mcp.json` |
93
- | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
94
- | VS Code | `.vscode/mcp.json` |
95
-
96
- Use the same JSON block shown above in any of these.
97
-
98
- ## Tools
99
-
100
- ### Config management (6)
101
-
102
- - **caddy_config_get** — Read config at any JSON path (or the full config).
103
- - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
104
- - **caddy_config_delete** — Delete config at a path.
105
- - **caddy_config_by_id** — Get/set/delete config by `@id` tag much easier than navigating deep paths.
106
- - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning. Auto-snapshots the prior config.
107
- - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10.
108
-
109
- ### Route operations (4)
110
-
111
- - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`. Pass an optional `id` for idempotent writes — repeat calls replace the route in place instead of duplicating.
112
- - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
113
- - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
114
- - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
115
-
116
- ### TLS & config conversion (2)
117
-
118
- - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. PATCH first; on a fresh install, POSTs a minimal config. On an existing config it deep-merges into the issuer path and PUTs the result back, preserving siblings (custom certs, `on_demand`, additional policies). Refuses with a shape-specific error if the existing structure is unexpected — never clobbers.
119
- - **caddy_adapt** — Convert a config in any registered adapter format to Caddy JSON without applying it. `caddyfile` (built-in, default) plus any adapter module compiled into your Caddy binary e.g., `nginx` ([caddy-nginx-adapter](https://github.com/caddyserver/nginx-adapter)), `yaml` ([caddy-yaml](https://github.com/abiosoft/caddy-yaml)). Great for previewing or porting from existing configs.
120
-
121
- ### Server operations (6)
122
-
123
- - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
124
- - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
125
- - **caddy_upstreams** — Reverse proxy backend health.
126
- - **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes). Optional `filter` (substring match on metric name, keeps `# HELP` / `# TYPE` lines for retained metrics) and `max_lines` (default 500) keep responses compact on busy servers.
127
- - **caddy_pki** — CA info and certificate chains (default CA: `local`).
128
- - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
129
-
130
- ## Resources
131
-
132
- Browsable read-only data — MCP clients can fetch these directly without a tool call:
133
-
134
- - `caddy://config` — Current full Caddy JSON configuration.
135
- - `caddy://servers` — Summary of all configured HTTP servers.
136
- - `caddy://upstreams` — Reverse proxy upstream health status.
137
- - `caddy://metrics` — Prometheus metrics (text exposition format). Capped at the first 500 lines to keep client context bounded; use the `caddy_metrics` tool with `filter` / `max_lines` for filtered or larger output.
138
-
139
- ## Examples
140
-
141
- ### Add a reverse proxy
142
-
143
- ```
144
- > "Proxy api.example.com to my app on port 3000"
145
- caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
146
- ```
147
-
148
- ### Idempotent reverse proxy (safe to re-run from automation)
149
-
150
- ```
151
- > "Make sure api.example.com points at localhost:3000, with a stable id"
152
- caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
153
- # First call creates the route under @id="api-prod".
154
- # Subsequent calls with the same id REPLACE in place — no duplicate routes.
155
- # Refuses with a clear error if "api-prod" is already in use by a non-route
156
- # config object (TLS issuer, server, etc.) @ids are config-global in Caddy.
157
- ```
158
-
159
- ### Filter Prometheus metrics
160
-
161
- ```
162
- > "Just the HTTP request metrics, please"
163
- caddy_metrics({ filter: "http_requests" })
164
- # Keeps sample lines whose metric name contains "http_requests",
165
- # plus their `# HELP` / `# TYPE` lines. Drops the rest.
166
- ```
167
-
168
- ### Preview a Caddyfile before applying it
169
-
170
- ```
171
- > "Convert this Caddyfile to JSON so I can review it:
172
- example.com {
173
- reverse_proxy localhost:8080
174
- }"
175
- → caddy_adapt({ config: "..." })
176
- ```
177
-
178
- ### Diagnose slow routes
179
-
180
- ```
181
- > "Fetch Prometheus metrics and tell me which route is slowest"
182
- caddy_metrics()
183
- ```
184
-
185
- ### Safely update a route by @id
186
-
187
- ```
188
- > "Update the route with @id 'api-v2' to point to the new backend"
189
- caddy_config_by_id({ id: "api-v2", action: "set", value: {...} })
190
- # Uses ETags you'll get HTTP 412 if someone else changed it first
191
- ```
192
-
193
- ### Atomic deploy
194
-
195
- ```
196
- > "Replace the whole config with this Caddyfile"
197
- caddy_adapt({ config: "..." }) # validate first
198
- caddy_load({ config: adaptedJson }) # apply atomically
199
- ```
200
-
201
- ## Troubleshooting
202
-
203
- **"Cannot connect to Caddy admin API"**
204
-
205
- - Make sure Caddy is running. `caddy run` or `systemctl status caddy`.
206
- - Check the admin endpoint. Default is `http://localhost:2019`. If Caddy is in Docker, use the container hostname.
207
- - Set `CADDY_ADMIN_URL` in your MCP config `env` to match.
208
-
209
- **"HTTP 412 Precondition Failed"**
210
-
211
- - Someone (or something) changed the config between your read and your write.
212
- - The cached ETag has been invalidated. Re-read the config and retry.
213
-
214
- **"HTTP 403" on /load or /config writes**
215
-
216
- - You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
217
- - Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
218
-
219
- **Windows: MCP server doesn't start**
220
-
221
- - Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
222
-
223
- ## Requirements
224
-
225
- - Node.js 20+
226
- - Caddy 2.x with admin API enabled (default: `localhost:2019`)
227
-
228
- ## Contributing
229
-
230
- ```bash
231
- git clone https://github.com/YawLabs/caddy-mcp.git
232
- cd caddy-mcp
233
- npm install
234
- npm run lint # Biome check
235
- npm run lint:fix # Auto-fix
236
- npm run build # tsup bundle
237
- npm test # Vitest (188 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
238
- npm run typecheck # tsc --noEmit
239
- ```
240
-
241
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release process.
242
-
243
- ## License
244
-
245
- MIT
1
+ # @yawlabs/caddy-mcp
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ [![GitHub stars](https://img.shields.io/github/stars/YawLabs/caddy-mcp)](https://github.com/YawLabs/caddy-mcp/stargazers)
6
+
7
+ **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 18 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics, snapshots.
8
+
9
+ Built and maintained by [Yaw Labs](https://yaw.sh).
10
+
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
+ ## Why this one?
16
+
17
+ Other Caddy MCP servers wrap half the admin API and silently swallow errors. This one doesn't.
18
+
19
+ - **Complete admin API coverage** — every documented endpoint: `/load`, `/config/*`, `/id/*`, `/stop`, `/adapt`, `/pki/ca/*`, `/reverse_proxy/upstreams`, `/metrics`. No placeholder tools that 404.
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.
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
+ - **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
+ - **Fallback error surfacing** — when a TLS write PATCH fails and the POST fallback also fails, both error bodies are returned so you know what actually went wrong.
25
+ - **Tool annotations** — every tool declares `readOnlyHint`, `destructiveHint`, and `idempotentHint`, so MCP clients can skip confirmations for safe ops.
26
+ - **Instant startup** — ships as a single bundle with two runtime deps (the MCP SDK + Zod). No 5-minute `node_modules` install.
27
+ - **Input hardening** — adapter names, `@id` values, server names, and CA ids are all regex-validated with length caps. Blocks CRLF header injection and ReDoS.
28
+
29
+ ## Quick start
30
+
31
+ **1. Enable the Caddy admin API**
32
+
33
+ Caddy ships with the admin API enabled on `localhost:2019` by default. If you're running Caddy in Docker or on a remote host, expose it via `CADDY_ADMIN_URL`.
34
+
35
+ **2. Create `.mcp.json` in your project root**
36
+
37
+ macOS / Linux / WSL:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "caddy": {
43
+ "command": "npx",
44
+ "args": ["-y", "@yawlabs/caddy-mcp@latest"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Windows:
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "caddy": {
56
+ "command": "cmd",
57
+ "args": ["/c", "npx", "-y", "@yawlabs/caddy-mcp@latest"]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ > **Why the extra step on Windows?** Since Node 20, `child_process.spawn` cannot directly execute `.cmd` files (that's what `npx` is on Windows). Wrapping with `cmd /c` is the standard workaround. This file is safe to commit — it contains no secrets.
64
+
65
+ **3. Restart and approve**
66
+
67
+ Restart Claude Code (or your MCP client) and approve the Caddy MCP server when prompted.
68
+
69
+ That's it. Now ask your AI assistant:
70
+
71
+ > "Proxy api.local to localhost:3000"
72
+ >
73
+ > "What routes are configured on srv0?"
74
+ >
75
+ > "Show me the Prometheus metrics"
76
+
77
+ ## Configuration
78
+
79
+ | Environment variable | Default | Description |
80
+ |---|---|---|
81
+ | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
82
+ | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
83
+ | `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures (5xx, network errors). 4xx and 412 never retry. POSTs to `/config/*` and `/id/*` also skip retry (non-idempotent appends/creates -- retrying could duplicate routes or 409 a half-applied create). POSTs to `/load`, `/adapt`, `/stop` still retry. Hard-capped at 5; values above the cap log a one-time stderr notice so the clamp is visible. Set to `0` to disable. |
84
+ | `CADDY_TIMEOUT` | `10000` | Timeout in ms for all admin API requests except `/load` (which uses `CADDY_LOAD_TIMEOUT`). Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
85
+ | `CADDY_LOAD_TIMEOUT` | `60000` | Timeout in ms for the `/load` endpoint; raise for ACME-heavy bring-ups where provisioning many certificates can exceed the default. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
86
+
87
+ **Alternate MCP clients:**
88
+
89
+ | Client | Config file |
90
+ |---|---|
91
+ | Claude Code | `.mcp.json` (project root) or `~/.claude.json` (global) |
92
+ | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
93
+ | Cursor | `~/.cursor/mcp.json` |
94
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
95
+ | VS Code | `.vscode/mcp.json` |
96
+
97
+ Use the same JSON block shown above in any of these.
98
+
99
+ ## Tools
100
+
101
+ ### Config management (6)
102
+
103
+ - **caddy_config_get** — Read config at any JSON path (or the full config).
104
+ - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
105
+ - **caddy_config_delete** — Delete config at a path. Requires `confirm=true` (deleting a parent path also removes every descendant).
106
+ - **caddy_config_by_id** — Get/set/delete config by `@id` tag much easier than navigating deep paths. The `delete` action requires `confirm=true`.
107
+ - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning. Auto-snapshots the prior config.
108
+ - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10.
109
+
110
+ ### Route operations (4)
111
+
112
+ - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`. Pass an optional `id` for idempotent writes — repeat calls replace the route in place instead of duplicating.
113
+ - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
114
+ - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
115
+ - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
116
+
117
+ ### TLS & config conversion (2)
118
+
119
+ - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. PATCH first; on a fresh install, POSTs a minimal config. On an existing config it deep-merges into the issuer path and PUTs the result back, preserving siblings (custom certs, `on_demand`, additional policies). Refuses with a shape-specific error if the existing structure is unexpected — never clobbers.
120
+ - **caddy_adapt** — Convert a config in any registered adapter format to Caddy JSON without applying it. `caddyfile` (built-in, default) plus any adapter module compiled into your Caddy binary — e.g., `nginx` ([caddy-nginx-adapter](https://github.com/caddyserver/nginx-adapter)), `yaml` ([caddy-yaml](https://github.com/abiosoft/caddy-yaml)). Great for previewing or porting from existing configs.
121
+
122
+ ### Server operations (6)
123
+
124
+ - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
125
+ - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
126
+ - **caddy_upstreams** — Reverse proxy backend health.
127
+ - **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes). Optional `filter` (substring match on metric name, keeps `# HELP` / `# TYPE` lines for retained metrics) and `max_lines` (default 500) keep responses compact on busy servers.
128
+ - **caddy_pki** — CA info and certificate chains (default CA: `local`).
129
+ - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
130
+
131
+ ## Resources
132
+
133
+ Browsable read-only data — MCP clients can fetch these directly without a tool call:
134
+
135
+ - `caddy://config` — Current full Caddy JSON configuration.
136
+ - `caddy://servers` — Summary of all configured HTTP servers.
137
+ - `caddy://upstreams` — Reverse proxy upstream health status.
138
+ - `caddy://metrics` — Prometheus metrics (text exposition format). Capped at the first 500 lines to keep client context bounded; use the `caddy_metrics` tool with `filter` / `max_lines` for filtered or larger output.
139
+
140
+ ## Examples
141
+
142
+ ### Add a reverse proxy
143
+
144
+ ```
145
+ > "Proxy api.example.com to my app on port 3000"
146
+ → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
147
+ ```
148
+
149
+ ### Idempotent reverse proxy (safe to re-run from automation)
150
+
151
+ ```
152
+ > "Make sure api.example.com points at localhost:3000, with a stable id"
153
+ caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
154
+ # First call creates the route under @id="api-prod".
155
+ # Subsequent calls with the same id REPLACE in place no duplicate routes.
156
+ # Refuses with a clear error if "api-prod" is already in use by a non-route
157
+ # config object (TLS issuer, server, etc.) — @ids are config-global in Caddy.
158
+ ```
159
+
160
+ ### Filter Prometheus metrics
161
+
162
+ ```
163
+ > "Just the HTTP request metrics, please"
164
+ caddy_metrics({ filter: "http_requests" })
165
+ # Keeps sample lines whose metric name contains "http_requests",
166
+ # plus their `# HELP` / `# TYPE` lines. Drops the rest.
167
+ ```
168
+
169
+ ### Preview a Caddyfile before applying it
170
+
171
+ ```
172
+ > "Convert this Caddyfile to JSON so I can review it:
173
+ example.com {
174
+ reverse_proxy localhost:8080
175
+ }"
176
+ → caddy_adapt({ config: "..." })
177
+ ```
178
+
179
+ ### Diagnose slow routes
180
+
181
+ ```
182
+ > "Fetch Prometheus metrics and tell me which route is slowest"
183
+ → caddy_metrics()
184
+ ```
185
+
186
+ ### Safely update a route by @id
187
+
188
+ ```
189
+ > "Update the route with @id 'api-v2' to point to the new backend"
190
+ caddy_config_by_id({ id: "api-v2", action: "set", value: {...} })
191
+ # Uses ETags — you'll get HTTP 412 if someone else changed it first
192
+ ```
193
+
194
+ ### Atomic deploy
195
+
196
+ ```
197
+ > "Replace the whole config with this Caddyfile"
198
+ caddy_adapt({ config: "..." }) # validate first
199
+ → caddy_load({ config: adaptedJson }) # apply atomically
200
+ ```
201
+
202
+ ## Troubleshooting
203
+
204
+ **"Cannot connect to Caddy admin API"**
205
+
206
+ - Make sure Caddy is running. `caddy run` or `systemctl status caddy`.
207
+ - Check the admin endpoint. Default is `http://localhost:2019`. If Caddy is in Docker, use the container hostname.
208
+ - Set `CADDY_ADMIN_URL` in your MCP config `env` to match.
209
+
210
+ **"HTTP 412 Precondition Failed"**
211
+
212
+ - Someone (or something) changed the config between your read and your write.
213
+ - The cached ETag has been invalidated. Re-read the config and retry.
214
+
215
+ **"HTTP 403" on /load or /config writes**
216
+
217
+ - You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
218
+ - Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
219
+
220
+ **Windows: MCP server doesn't start**
221
+
222
+ - Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
223
+
224
+ ## Requirements
225
+
226
+ - Node.js 20+
227
+ - Caddy 2.x with admin API enabled (default: `localhost:2019`)
228
+
229
+ ## Contributing
230
+
231
+ ```bash
232
+ git clone https://github.com/YawLabs/caddy-mcp.git
233
+ cd caddy-mcp
234
+ npm install
235
+ npm run lint # Biome check
236
+ npm run lint:fix # Auto-fix
237
+ npm run build # tsup bundle
238
+ npm test # Vitest (230 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
239
+ npm run typecheck # tsc --noEmit
240
+ ```
241
+
242
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release process.
243
+
244
+ ## License
245
+
246
+ MIT
package/dist/index.js CHANGED
@@ -102,7 +102,7 @@ async function caddyRequest(method, path, body, contentType, timeout) {
102
102
  }
103
103
  async function attemptRequest(method, path, body, contentType, timeout) {
104
104
  const url = `${getBaseUrl()}${path}`;
105
- const effectiveTimeout = timeout ?? TIMEOUT;
105
+ const effectiveTimeout = timeout ?? getRequestTimeout();
106
106
  try {
107
107
  const hasBody = body !== void 0;
108
108
  const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
@@ -138,7 +138,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
138
138
  error: "Config has been modified since it was last read (HTTP 412 Precondition Failed). Re-read the config and retry your change."
139
139
  };
140
140
  }
141
- return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
141
+ if (!text) {
142
+ const hint = res.status === 401 || res.status === 403 ? " -- check CADDY_API_TOKEN" : "";
143
+ return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
144
+ }
145
+ return { ok: false, status: res.status, error: text };
142
146
  }
143
147
  if (!text) return { ok: true, status: res.status, etag };
144
148
  try {
@@ -197,6 +201,15 @@ function configDelete(path) {
197
201
  if (bad) return Promise.resolve(bad);
198
202
  return caddyRequest("DELETE", `/config/${normalized}`);
199
203
  }
204
+ function getRequestTimeout() {
205
+ const raw = process.env.CADDY_TIMEOUT;
206
+ if (raw === void 0) return TIMEOUT;
207
+ const n = Number(raw);
208
+ if (!Number.isFinite(n)) return TIMEOUT;
209
+ const floored = Math.floor(n);
210
+ if (floored < 1) return TIMEOUT;
211
+ return floored;
212
+ }
200
213
  function getLoadTimeout() {
201
214
  const raw = process.env.CADDY_LOAD_TIMEOUT;
202
215
  if (raw === void 0) return 6e4;
@@ -312,7 +325,7 @@ function applyMetricsControls(raw, filter, maxLines) {
312
325
  if (filtered.length <= maxLines) return filtered.join("\n");
313
326
  const dropped = filtered.length - maxLines;
314
327
  const kept = filtered.slice(0, maxLines);
315
- kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
328
+ kept.push(`# [truncated, ${dropped} lines omitted; max_lines=${maxLines} -- use filter or raise max_lines]`);
316
329
  const keptHasEof = kept.some((l) => l.trim() === "# EOF");
317
330
  if (!keptHasEof && filtered.slice(maxLines).some((l) => l.trim() === "# EOF")) {
318
331
  kept.push("# EOF");
@@ -441,7 +454,7 @@ function registerResources(server) {
441
454
  {
442
455
  uri: "caddy://config",
443
456
  mimeType: res.ok ? "application/json" : "text/plain",
444
- text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
457
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
445
458
  }
446
459
  ]
447
460
  };
@@ -457,7 +470,7 @@ function registerResources(server) {
457
470
  {
458
471
  uri: "caddy://upstreams",
459
472
  mimeType: res.ok ? "application/json" : "text/plain",
460
- text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
473
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
461
474
  }
462
475
  ]
463
476
  };
@@ -603,10 +616,26 @@ function registerConfigTools(server) {
603
616
  );
604
617
  server.tool(
605
618
  "caddy_config_delete",
606
- "Delete config at a JSON path. Removes the config node at the specified path.",
607
- { path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
619
+ "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.",
620
+ {
621
+ path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
622
+ confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
623
+ },
608
624
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
609
- async ({ path }) => formatResult(await configDelete(path))
625
+ async ({ path, confirm }) => {
626
+ if (!confirm) {
627
+ return {
628
+ isError: true,
629
+ content: [
630
+ {
631
+ type: "text",
632
+ text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
633
+ }
634
+ ]
635
+ };
636
+ }
637
+ return formatResult(await configDelete(path));
638
+ }
610
639
  );
611
640
  server.tool(
612
641
  "caddy_load",
@@ -705,7 +734,7 @@ ${lines.join("\n")}` }] };
705
734
  );
706
735
  server.tool(
707
736
  "caddy_config_by_id",
708
- "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.",
737
+ "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.",
709
738
  {
710
739
  id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
711
740
  action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
@@ -713,10 +742,11 @@ ${lines.join("\n")}` }] };
713
742
  subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
714
743
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
715
744
  "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
716
- )
745
+ ),
746
+ confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
717
747
  },
718
748
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
719
- async ({ id, action, value, subpath, mode }) => {
749
+ async ({ id, action, value, subpath, mode, confirm }) => {
720
750
  if (action === "get") {
721
751
  return formatResult(await configByIdGet(id, subpath));
722
752
  }
@@ -731,6 +761,18 @@ ${lines.join("\n")}` }] };
731
761
  return formatResult(await configByIdSet(id, value, method, subpath));
732
762
  }
733
763
  if (action === "delete") {
764
+ if (!confirm) {
765
+ const target = subpath ? `@id="${id}" subpath "${subpath}"` : `@id="${id}"`;
766
+ return {
767
+ isError: true,
768
+ content: [
769
+ {
770
+ type: "text",
771
+ text: `Refusing to delete ${target} without confirm=true. Re-run with confirm:true to proceed.`
772
+ }
773
+ ]
774
+ };
775
+ }
734
776
  return formatResult(await configByIdDelete(id, subpath));
735
777
  }
736
778
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
@@ -791,13 +833,13 @@ function isRouteShape(obj) {
791
833
  if (!obj || typeof obj !== "object") return false;
792
834
  return Array.isArray(obj.handle);
793
835
  }
794
- function serverNotFoundError(srv) {
836
+ function serverNotFoundError(srv, op = "operation") {
795
837
  return {
796
838
  isError: true,
797
839
  content: [
798
840
  {
799
841
  type: "text",
800
- text: `Error: Server "${srv}" does not exist. Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
842
+ text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
801
843
  }
802
844
  ]
803
845
  };
@@ -871,7 +913,7 @@ function registerRouteTools(server) {
871
913
  };
872
914
  }
873
915
  if (isParentMissing(postRes)) {
874
- return serverNotFoundError(srv);
916
+ return serverNotFoundError(srv, "caddy_reverse_proxy");
875
917
  }
876
918
  return formatResult(postRes);
877
919
  }
@@ -880,7 +922,7 @@ function registerRouteTools(server) {
880
922
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
881
923
  }
882
924
  if (isParentMissing(res)) {
883
- return serverNotFoundError(srv);
925
+ return serverNotFoundError(srv, "caddy_reverse_proxy");
884
926
  }
885
927
  return formatResult(res);
886
928
  }
@@ -899,7 +941,7 @@ function registerRouteTools(server) {
899
941
  const route = { match, handle, terminal };
900
942
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
901
943
  if (isParentMissing(res)) {
902
- return serverNotFoundError(srv);
944
+ return serverNotFoundError(srv, "caddy_add_route");
903
945
  }
904
946
  return formatResult(res);
905
947
  }
@@ -1070,13 +1112,25 @@ function registerRouteTools(server) {
1070
1112
  if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
1071
1113
  return formatResult(res2);
1072
1114
  }
1115
+ if (index === void 0) {
1116
+ return {
1117
+ isError: true,
1118
+ content: [{ type: "text", text: "Error: must provide either id or index" }]
1119
+ };
1120
+ }
1073
1121
  const readRes = await configGet(`apps/http/servers/${srv}/routes`);
1074
1122
  if (!readRes.ok) return formatResult(readRes);
1075
1123
  const routes = readRes.data;
1124
+ if (routes === void 0 || routes === null) {
1125
+ return {
1126
+ isError: true,
1127
+ content: [{ type: "text", text: `Error: server "${srv}" has no routes configured` }]
1128
+ };
1129
+ }
1076
1130
  if (!Array.isArray(routes)) {
1077
1131
  return {
1078
1132
  isError: true,
1079
- content: [{ type: "text", text: `Error: server "${srv}" has no routes array (or it is malformed)` }]
1133
+ content: [{ type: "text", text: `Error: server "${srv}" routes value is malformed (not an array)` }]
1080
1134
  };
1081
1135
  }
1082
1136
  if (index >= routes.length) {
package/dist/server.js CHANGED
@@ -100,7 +100,7 @@ async function caddyRequest(method, path, body, contentType, timeout) {
100
100
  }
101
101
  async function attemptRequest(method, path, body, contentType, timeout) {
102
102
  const url = `${getBaseUrl()}${path}`;
103
- const effectiveTimeout = timeout ?? TIMEOUT;
103
+ const effectiveTimeout = timeout ?? getRequestTimeout();
104
104
  try {
105
105
  const hasBody = body !== void 0;
106
106
  const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
@@ -136,7 +136,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
136
136
  error: "Config has been modified since it was last read (HTTP 412 Precondition Failed). Re-read the config and retry your change."
137
137
  };
138
138
  }
139
- return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
139
+ if (!text) {
140
+ const hint = res.status === 401 || res.status === 403 ? " -- check CADDY_API_TOKEN" : "";
141
+ return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
142
+ }
143
+ return { ok: false, status: res.status, error: text };
140
144
  }
141
145
  if (!text) return { ok: true, status: res.status, etag };
142
146
  try {
@@ -195,6 +199,15 @@ function configDelete(path) {
195
199
  if (bad) return Promise.resolve(bad);
196
200
  return caddyRequest("DELETE", `/config/${normalized}`);
197
201
  }
202
+ function getRequestTimeout() {
203
+ const raw = process.env.CADDY_TIMEOUT;
204
+ if (raw === void 0) return TIMEOUT;
205
+ const n = Number(raw);
206
+ if (!Number.isFinite(n)) return TIMEOUT;
207
+ const floored = Math.floor(n);
208
+ if (floored < 1) return TIMEOUT;
209
+ return floored;
210
+ }
198
211
  function getLoadTimeout() {
199
212
  const raw = process.env.CADDY_LOAD_TIMEOUT;
200
213
  if (raw === void 0) return 6e4;
@@ -310,7 +323,7 @@ function applyMetricsControls(raw, filter, maxLines) {
310
323
  if (filtered.length <= maxLines) return filtered.join("\n");
311
324
  const dropped = filtered.length - maxLines;
312
325
  const kept = filtered.slice(0, maxLines);
313
- kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
326
+ kept.push(`# [truncated, ${dropped} lines omitted; max_lines=${maxLines} -- use filter or raise max_lines]`);
314
327
  const keptHasEof = kept.some((l) => l.trim() === "# EOF");
315
328
  if (!keptHasEof && filtered.slice(maxLines).some((l) => l.trim() === "# EOF")) {
316
329
  kept.push("# EOF");
@@ -439,7 +452,7 @@ function registerResources(server) {
439
452
  {
440
453
  uri: "caddy://config",
441
454
  mimeType: res.ok ? "application/json" : "text/plain",
442
- text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
455
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
443
456
  }
444
457
  ]
445
458
  };
@@ -455,7 +468,7 @@ function registerResources(server) {
455
468
  {
456
469
  uri: "caddy://upstreams",
457
470
  mimeType: res.ok ? "application/json" : "text/plain",
458
- text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
471
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
459
472
  }
460
473
  ]
461
474
  };
@@ -601,10 +614,26 @@ function registerConfigTools(server) {
601
614
  );
602
615
  server.tool(
603
616
  "caddy_config_delete",
604
- "Delete config at a JSON path. Removes the config node at the specified path.",
605
- { path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
617
+ "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.",
618
+ {
619
+ path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
620
+ confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
621
+ },
606
622
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
607
- async ({ path }) => formatResult(await configDelete(path))
623
+ async ({ path, confirm }) => {
624
+ if (!confirm) {
625
+ return {
626
+ isError: true,
627
+ content: [
628
+ {
629
+ type: "text",
630
+ text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
631
+ }
632
+ ]
633
+ };
634
+ }
635
+ return formatResult(await configDelete(path));
636
+ }
608
637
  );
609
638
  server.tool(
610
639
  "caddy_load",
@@ -703,7 +732,7 @@ ${lines.join("\n")}` }] };
703
732
  );
704
733
  server.tool(
705
734
  "caddy_config_by_id",
706
- "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.",
735
+ "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.",
707
736
  {
708
737
  id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
709
738
  action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
@@ -711,10 +740,11 @@ ${lines.join("\n")}` }] };
711
740
  subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
712
741
  mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
713
742
  "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
714
- )
743
+ ),
744
+ confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
715
745
  },
716
746
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
717
- async ({ id, action, value, subpath, mode }) => {
747
+ async ({ id, action, value, subpath, mode, confirm }) => {
718
748
  if (action === "get") {
719
749
  return formatResult(await configByIdGet(id, subpath));
720
750
  }
@@ -729,6 +759,18 @@ ${lines.join("\n")}` }] };
729
759
  return formatResult(await configByIdSet(id, value, method, subpath));
730
760
  }
731
761
  if (action === "delete") {
762
+ if (!confirm) {
763
+ const target = subpath ? `@id="${id}" subpath "${subpath}"` : `@id="${id}"`;
764
+ return {
765
+ isError: true,
766
+ content: [
767
+ {
768
+ type: "text",
769
+ text: `Refusing to delete ${target} without confirm=true. Re-run with confirm:true to proceed.`
770
+ }
771
+ ]
772
+ };
773
+ }
732
774
  return formatResult(await configByIdDelete(id, subpath));
733
775
  }
734
776
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
@@ -789,13 +831,13 @@ function isRouteShape(obj) {
789
831
  if (!obj || typeof obj !== "object") return false;
790
832
  return Array.isArray(obj.handle);
791
833
  }
792
- function serverNotFoundError(srv) {
834
+ function serverNotFoundError(srv, op = "operation") {
793
835
  return {
794
836
  isError: true,
795
837
  content: [
796
838
  {
797
839
  type: "text",
798
- text: `Error: Server "${srv}" does not exist. Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
840
+ text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
799
841
  }
800
842
  ]
801
843
  };
@@ -869,7 +911,7 @@ function registerRouteTools(server) {
869
911
  };
870
912
  }
871
913
  if (isParentMissing(postRes)) {
872
- return serverNotFoundError(srv);
914
+ return serverNotFoundError(srv, "caddy_reverse_proxy");
873
915
  }
874
916
  return formatResult(postRes);
875
917
  }
@@ -878,7 +920,7 @@ function registerRouteTools(server) {
878
920
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
879
921
  }
880
922
  if (isParentMissing(res)) {
881
- return serverNotFoundError(srv);
923
+ return serverNotFoundError(srv, "caddy_reverse_proxy");
882
924
  }
883
925
  return formatResult(res);
884
926
  }
@@ -897,7 +939,7 @@ function registerRouteTools(server) {
897
939
  const route = { match, handle, terminal };
898
940
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
899
941
  if (isParentMissing(res)) {
900
- return serverNotFoundError(srv);
942
+ return serverNotFoundError(srv, "caddy_add_route");
901
943
  }
902
944
  return formatResult(res);
903
945
  }
@@ -1068,13 +1110,25 @@ function registerRouteTools(server) {
1068
1110
  if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
1069
1111
  return formatResult(res2);
1070
1112
  }
1113
+ if (index === void 0) {
1114
+ return {
1115
+ isError: true,
1116
+ content: [{ type: "text", text: "Error: must provide either id or index" }]
1117
+ };
1118
+ }
1071
1119
  const readRes = await configGet(`apps/http/servers/${srv}/routes`);
1072
1120
  if (!readRes.ok) return formatResult(readRes);
1073
1121
  const routes = readRes.data;
1122
+ if (routes === void 0 || routes === null) {
1123
+ return {
1124
+ isError: true,
1125
+ content: [{ type: "text", text: `Error: server "${srv}" has no routes configured` }]
1126
+ };
1127
+ }
1074
1128
  if (!Array.isArray(routes)) {
1075
1129
  return {
1076
1130
  isError: true,
1077
- content: [{ type: "text", text: `Error: server "${srv}" has no routes array (or it is malformed)` }]
1131
+ content: [{ type: "text", text: `Error: server "${srv}" routes value is malformed (not an array)` }]
1078
1132
  };
1079
1133
  }
1080
1134
  if (index >= routes.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "MCP server for managing Caddy web servers via the admin API",
6
6
  "license": "MIT",
@@ -40,11 +40,12 @@
40
40
  "zod": "^4.3.6"
41
41
  },
42
42
  "overrides": {
43
- "hono": "^4.12.18",
43
+ "hono": "^4.12.21",
44
44
  "@hono/node-server": "^1.19.13",
45
45
  "postcss": "^8.5.10",
46
46
  "ip-address": "^10.1.1",
47
- "fast-uri": "^3.1.2"
47
+ "fast-uri": "^3.1.2",
48
+ "qs": "^6.15.2"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@biomejs/biome": "^2.4.11",