@yawlabs/caddy-mcp 1.2.8 → 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 +246 -245
- package/dist/index.js +71 -17
- package/dist/server.js +71 -17
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,245 +1,246 @@
|
|
|
1
|
-
# @yawlabs/caddy-mcp
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
|
-
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](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
|
-
[](yaw
|
|
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
|
-
| `
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
| Claude
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
- **
|
|
104
|
-
- **
|
|
105
|
-
- **
|
|
106
|
-
- **
|
|
107
|
-
- **
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
- **
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- **
|
|
125
|
-
- **
|
|
126
|
-
- **
|
|
127
|
-
- **
|
|
128
|
-
- **
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
- `caddy://
|
|
136
|
-
- `caddy://
|
|
137
|
-
- `caddy://
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
→
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
npm
|
|
235
|
-
npm run lint
|
|
236
|
-
npm run
|
|
237
|
-
npm
|
|
238
|
-
npm
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
1
|
+
# @yawlabs/caddy-mcp
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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
|
+
[](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 ??
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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 }) =>
|
|
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}"
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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 }) =>
|
|
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}"
|
|
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.
|
|
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.
|
|
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",
|