@yawlabs/caddy-mcp 2.3.0 → 2.3.1

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.
Files changed (3) hide show
  1. package/README.md +276 -276
  2. package/bin/caddy-mcp.mjs +334 -175
  3. package/package.json +82 -82
package/README.md CHANGED
@@ -1,276 +1,276 @@
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. Also accepts a unix socket, in either `unix:///var/run/caddy-admin.sock` or Caddy's own `unix//var/run/caddy-admin.sock` spelling — see below. |
82
- | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
83
- | `CADDY_MCP_SNAPSHOT_DIR` | (none) | Directory for persisting `caddy_revert` snapshots. Unset, snapshots live in memory only and are lost when this server restarts. Snapshots are full Caddy configs and can contain secrets, so the location is opt-in rather than defaulted. |
84
- | `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. |
85
- | `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. |
86
- | `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. |
87
-
88
- **Unix socket admin endpoints:**
89
-
90
- Caddy's recommended hardening is to move the admin API off a loopback port and
91
- onto a unix socket, where access is governed by filesystem permissions:
92
-
93
- ```
94
- {
95
- admin unix//var/run/caddy-admin.sock
96
- }
97
- ```
98
-
99
- Point `CADDY_ADMIN_URL` at the same path (`unix:///var/run/caddy-admin.sock`)
100
- and requests are sent over the socket instead of TCP. The process running
101
- caddy-mcp needs read/write permission on the socket file. `CADDY_API_TOKEN`
102
- still applies if you have auth in front of the endpoint.
103
-
104
- **Alternate MCP clients:**
105
-
106
- | Client | Config file |
107
- |---|---|
108
- | Claude Code | `.mcp.json` (project root) or `~/.claude.json` (global) |
109
- | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
110
- | Cursor | `~/.cursor/mcp.json` |
111
- | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
112
- | VS Code | `.vscode/mcp.json` |
113
-
114
- Use the same JSON block shown above in any of these.
115
-
116
- ## Tools
117
-
118
- ### Config management (6)
119
-
120
- - **caddy_config_get** — Read config at any JSON path (or the full config).
121
- - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
122
- - **caddy_config_delete** — Delete config at a path. Requires `confirm=true` (deleting a parent path also removes every descendant).
123
- - **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths. The `delete` action requires `confirm=true`.
124
- - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning. Auto-snapshots the prior config.
125
- - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10.
126
-
127
- ### Route operations (4)
128
-
129
- - **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.
130
- - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
131
- - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
132
- - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
133
-
134
- ### TLS & config conversion (2)
135
-
136
- - **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.
137
- - **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.
138
-
139
- ### Server operations (6)
140
-
141
- - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
142
- - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
143
- - **caddy_upstreams** — Reverse proxy backend health.
144
- - **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.
145
- - **caddy_pki** — CA info and certificate chains (default CA: `local`).
146
- - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
147
-
148
- ## Resources
149
-
150
- Browsable read-only data — MCP clients can fetch these directly without a tool call:
151
-
152
- - `caddy://config` — Current full Caddy JSON configuration.
153
- - `caddy://servers` — Summary of all configured HTTP servers.
154
- - `caddy://upstreams` — Reverse proxy upstream health status.
155
- - `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.
156
-
157
- ## Examples
158
-
159
- ### Add a reverse proxy
160
-
161
- ```
162
- > "Proxy api.example.com to my app on port 3000"
163
- → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
164
- ```
165
-
166
- ### Idempotent reverse proxy (safe to re-run from automation)
167
-
168
- ```
169
- > "Make sure api.example.com points at localhost:3000, with a stable id"
170
- → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
171
- # First call creates the route under @id="api-prod".
172
- # Subsequent calls with the same id REPLACE in place — no duplicate routes.
173
- # Refuses with a clear error if "api-prod" is already in use by a non-route
174
- # config object (TLS issuer, server, etc.) — @ids are config-global in Caddy.
175
- ```
176
-
177
- ### Filter Prometheus metrics
178
-
179
- ```
180
- > "Just the HTTP request metrics, please"
181
- → caddy_metrics({ filter: "http_requests" })
182
- # Keeps sample lines whose metric name contains "http_requests",
183
- # plus their `# HELP` / `# TYPE` lines. Drops the rest.
184
- ```
185
-
186
- ### Preview a Caddyfile before applying it
187
-
188
- ```
189
- > "Convert this Caddyfile to JSON so I can review it:
190
- example.com {
191
- reverse_proxy localhost:8080
192
- }"
193
- → caddy_adapt({ config: "..." })
194
- ```
195
-
196
- ### Diagnose slow routes
197
-
198
- ```
199
- > "Fetch Prometheus metrics and tell me which route is slowest"
200
- → caddy_metrics()
201
- ```
202
-
203
- ### Safely update a route by @id
204
-
205
- ```
206
- > "Update the route with @id 'api-v2' to point to the new backend"
207
- → caddy_config_by_id({ id: "api-v2", action: "set", value: {...} })
208
- # Uses ETags — you'll get HTTP 412 if someone else changed it first
209
- ```
210
-
211
- ### Atomic deploy
212
-
213
- ```
214
- > "Replace the whole config with this Caddyfile"
215
- → caddy_adapt({ config: "..." }) # validate first
216
- → caddy_load({ config: adaptedJson }) # apply atomically
217
- ```
218
-
219
- ## Troubleshooting
220
-
221
- **"Cannot connect to Caddy admin API"**
222
-
223
- - Make sure Caddy is running. `caddy run` or `systemctl status caddy`.
224
- - Check the admin endpoint. Default is `http://localhost:2019`. If Caddy is in Docker, use the container hostname.
225
- - Set `CADDY_ADMIN_URL` in your MCP config `env` to match.
226
-
227
- **"HTTP 412 Precondition Failed"**
228
-
229
- - Someone (or something) changed the config between your read and your write.
230
- - The cached ETag has been invalidated. Re-read the config and retry.
231
-
232
- **"HTTP 403" on /load or /config writes**
233
-
234
- - You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
235
- - Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
236
-
237
- **`SIGUSR1` / `systemctl reload caddy` stops reloading the Caddyfile**
238
-
239
- - Expected, and not caused by a bug here. Since Caddy 2.11.1, `SIGUSR1` reloads
240
- from the file on disk **only if the config has never been changed through the
241
- admin API**. The first write from caddy-mcp (or any other API client) makes
242
- Caddy consider the running config API-owned, and `SIGUSR1` becomes a no-op.
243
- - Pick one owner per instance. If the Caddyfile is the source of truth, use
244
- caddy-mcp read-only tools (`caddy_status`, `caddy_list_routes`, `caddy_adapt`)
245
- and reload from the file. If caddy-mcp owns the config, apply changes with
246
- `caddy_load` instead of `SIGUSR1`.
247
-
248
- **Windows: MCP server doesn't start**
249
-
250
- - Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
251
-
252
- ## Requirements
253
-
254
- - Node.js 20+
255
- - Caddy 2.x with admin API enabled (default: `localhost:2019`). Verified against
256
- Caddy 2.11.4; the `@id` write path relies on `PATCH` semantics that the live
257
- integration suite pins per release.
258
-
259
- ## Contributing
260
-
261
- ```bash
262
- git clone https://github.com/YawLabs/caddy-mcp.git
263
- cd caddy-mcp
264
- npm install
265
- npm run lint # Biome check
266
- npm run lint:fix # Auto-fix
267
- npm run build # tsup bundle
268
- npm test # Vitest (357 unit tests, +9 POSIX-only unix-socket tests; +13 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
269
- npm run typecheck # tsc --noEmit
270
- ```
271
-
272
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release process.
273
-
274
- ## License
275
-
276
- 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. Also accepts a unix socket, in either `unix:///var/run/caddy-admin.sock` or Caddy's own `unix//var/run/caddy-admin.sock` spelling — see below. |
82
+ | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
83
+ | `CADDY_MCP_SNAPSHOT_DIR` | (none) | Directory for persisting `caddy_revert` snapshots. Unset, snapshots live in memory only and are lost when this server restarts. Snapshots are full Caddy configs and can contain secrets, so the location is opt-in rather than defaulted. |
84
+ | `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. |
85
+ | `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. |
86
+ | `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. |
87
+
88
+ **Unix socket admin endpoints:**
89
+
90
+ Caddy's recommended hardening is to move the admin API off a loopback port and
91
+ onto a unix socket, where access is governed by filesystem permissions:
92
+
93
+ ```
94
+ {
95
+ admin unix//var/run/caddy-admin.sock
96
+ }
97
+ ```
98
+
99
+ Point `CADDY_ADMIN_URL` at the same path (`unix:///var/run/caddy-admin.sock`)
100
+ and requests are sent over the socket instead of TCP. The process running
101
+ caddy-mcp needs read/write permission on the socket file. `CADDY_API_TOKEN`
102
+ still applies if you have auth in front of the endpoint.
103
+
104
+ **Alternate MCP clients:**
105
+
106
+ | Client | Config file |
107
+ |---|---|
108
+ | Claude Code | `.mcp.json` (project root) or `~/.claude.json` (global) |
109
+ | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
110
+ | Cursor | `~/.cursor/mcp.json` |
111
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
112
+ | VS Code | `.vscode/mcp.json` |
113
+
114
+ Use the same JSON block shown above in any of these.
115
+
116
+ ## Tools
117
+
118
+ ### Config management (6)
119
+
120
+ - **caddy_config_get** — Read config at any JSON path (or the full config).
121
+ - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
122
+ - **caddy_config_delete** — Delete config at a path. Requires `confirm=true` (deleting a parent path also removes every descendant).
123
+ - **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths. The `delete` action requires `confirm=true`.
124
+ - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning. Auto-snapshots the prior config.
125
+ - **caddy_revert** — Manage config snapshots for rollback. Actions: `list`, `save`, `apply` (confirm-gated). In-memory, last 10.
126
+
127
+ ### Route operations (4)
128
+
129
+ - **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.
130
+ - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
131
+ - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
132
+ - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
133
+
134
+ ### TLS & config conversion (2)
135
+
136
+ - **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.
137
+ - **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.
138
+
139
+ ### Server operations (6)
140
+
141
+ - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
142
+ - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
143
+ - **caddy_upstreams** — Reverse proxy backend health.
144
+ - **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.
145
+ - **caddy_pki** — CA info and certificate chains (default CA: `local`).
146
+ - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
147
+
148
+ ## Resources
149
+
150
+ Browsable read-only data — MCP clients can fetch these directly without a tool call:
151
+
152
+ - `caddy://config` — Current full Caddy JSON configuration.
153
+ - `caddy://servers` — Summary of all configured HTTP servers.
154
+ - `caddy://upstreams` — Reverse proxy upstream health status.
155
+ - `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.
156
+
157
+ ## Examples
158
+
159
+ ### Add a reverse proxy
160
+
161
+ ```
162
+ > "Proxy api.example.com to my app on port 3000"
163
+ → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
164
+ ```
165
+
166
+ ### Idempotent reverse proxy (safe to re-run from automation)
167
+
168
+ ```
169
+ > "Make sure api.example.com points at localhost:3000, with a stable id"
170
+ → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
171
+ # First call creates the route under @id="api-prod".
172
+ # Subsequent calls with the same id REPLACE in place — no duplicate routes.
173
+ # Refuses with a clear error if "api-prod" is already in use by a non-route
174
+ # config object (TLS issuer, server, etc.) — @ids are config-global in Caddy.
175
+ ```
176
+
177
+ ### Filter Prometheus metrics
178
+
179
+ ```
180
+ > "Just the HTTP request metrics, please"
181
+ → caddy_metrics({ filter: "http_requests" })
182
+ # Keeps sample lines whose metric name contains "http_requests",
183
+ # plus their `# HELP` / `# TYPE` lines. Drops the rest.
184
+ ```
185
+
186
+ ### Preview a Caddyfile before applying it
187
+
188
+ ```
189
+ > "Convert this Caddyfile to JSON so I can review it:
190
+ example.com {
191
+ reverse_proxy localhost:8080
192
+ }"
193
+ → caddy_adapt({ config: "..." })
194
+ ```
195
+
196
+ ### Diagnose slow routes
197
+
198
+ ```
199
+ > "Fetch Prometheus metrics and tell me which route is slowest"
200
+ → caddy_metrics()
201
+ ```
202
+
203
+ ### Safely update a route by @id
204
+
205
+ ```
206
+ > "Update the route with @id 'api-v2' to point to the new backend"
207
+ → caddy_config_by_id({ id: "api-v2", action: "set", value: {...} })
208
+ # Uses ETags — you'll get HTTP 412 if someone else changed it first
209
+ ```
210
+
211
+ ### Atomic deploy
212
+
213
+ ```
214
+ > "Replace the whole config with this Caddyfile"
215
+ → caddy_adapt({ config: "..." }) # validate first
216
+ → caddy_load({ config: adaptedJson }) # apply atomically
217
+ ```
218
+
219
+ ## Troubleshooting
220
+
221
+ **"Cannot connect to Caddy admin API"**
222
+
223
+ - Make sure Caddy is running. `caddy run` or `systemctl status caddy`.
224
+ - Check the admin endpoint. Default is `http://localhost:2019`. If Caddy is in Docker, use the container hostname.
225
+ - Set `CADDY_ADMIN_URL` in your MCP config `env` to match.
226
+
227
+ **"HTTP 412 Precondition Failed"**
228
+
229
+ - Someone (or something) changed the config between your read and your write.
230
+ - The cached ETag has been invalidated. Re-read the config and retry.
231
+
232
+ **"HTTP 403" on /load or /config writes**
233
+
234
+ - You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
235
+ - Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
236
+
237
+ **`SIGUSR1` / `systemctl reload caddy` stops reloading the Caddyfile**
238
+
239
+ - Expected, and not caused by a bug here. Since Caddy 2.11.1, `SIGUSR1` reloads
240
+ from the file on disk **only if the config has never been changed through the
241
+ admin API**. The first write from caddy-mcp (or any other API client) makes
242
+ Caddy consider the running config API-owned, and `SIGUSR1` becomes a no-op.
243
+ - Pick one owner per instance. If the Caddyfile is the source of truth, use
244
+ caddy-mcp read-only tools (`caddy_status`, `caddy_list_routes`, `caddy_adapt`)
245
+ and reload from the file. If caddy-mcp owns the config, apply changes with
246
+ `caddy_load` instead of `SIGUSR1`.
247
+
248
+ **Windows: MCP server doesn't start**
249
+
250
+ - Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
251
+
252
+ ## Requirements
253
+
254
+ - Node.js 20+
255
+ - Caddy 2.x with admin API enabled (default: `localhost:2019`). Verified against
256
+ Caddy 2.11.4; the `@id` write path relies on `PATCH` semantics that the live
257
+ integration suite pins per release.
258
+
259
+ ## Contributing
260
+
261
+ ```bash
262
+ git clone https://github.com/YawLabs/caddy-mcp.git
263
+ cd caddy-mcp
264
+ npm install
265
+ npm run lint # Biome check
266
+ npm run lint:fix # Auto-fix
267
+ npm run build # tsup bundle
268
+ npm test # Vitest (357 unit tests, +9 POSIX-only unix-socket tests; +13 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
269
+ npm run typecheck # tsc --noEmit
270
+ ```
271
+
272
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release process.
273
+
274
+ ## License
275
+
276
+ MIT
package/bin/caddy-mcp.mjs CHANGED
@@ -1,33 +1,33 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Runtime launcher for @yawlabs/caddy-mcp.
4
- *
5
- * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
- * process already running this file.
7
- *
8
- * Unlike npmjs-mcp, this server is NOT a zero-dependency bundle -- dist/
9
- * imports @modelcontextprotocol/sdk and zod from node_modules at runtime. That
10
- * is fine on both paths: oam does npm resolution against an existing
11
- * node_modules with CommonJS interop, and it was verified here before this
12
- * launcher was written (`oam run dist/index.js -- --version` prints the same
13
- * version Node does).
14
- *
15
- * WHY THE FALLBACK COSTS NOTHING
16
- * npm has already started Node to run this launcher, so falling back is a
17
- * plain `import()` of the server into THIS process: no extra spawn, no extra
18
- * startup, byte-identical to invoking dist/index.js directly. Discovery is
19
- * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
20
- *
21
- * WHAT THE OAM PATH COSTS
22
- * Reaching oam through an npm `bin` means Node boots first and oam boots
23
- * second, so the launcher is slower than either runtime alone. Measured on
24
- * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
25
- * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
26
- * launcher is the slowest path -- it exists for `npx` convenience.
27
- *
28
- * For an MCP host config, point straight at oam and skip this file:
29
- * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
30
- *
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/caddy-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file.
7
+ *
8
+ * Unlike npmjs-mcp, this server is NOT a zero-dependency bundle -- dist/
9
+ * imports @modelcontextprotocol/sdk and zod from node_modules at runtime. That
10
+ * is fine on both paths: oam does npm resolution against an existing
11
+ * node_modules with CommonJS interop, and it was verified here before this
12
+ * launcher was written (`oam run dist/index.js -- --version` prints the same
13
+ * version Node does).
14
+ *
15
+ * WHY THE FALLBACK COSTS NOTHING
16
+ * npm has already started Node to run this launcher, so falling back is a
17
+ * plain `import()` of the server into THIS process: no extra spawn, no extra
18
+ * startup, byte-identical to invoking dist/index.js directly. Discovery is
19
+ * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
20
+ *
21
+ * WHAT THE OAM PATH COSTS
22
+ * Reaching oam through an npm `bin` means Node boots first and oam boots
23
+ * second, so the launcher is slower than either runtime alone. Measured on
24
+ * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
25
+ * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
26
+ * launcher is the slowest path -- it exists for `npx` convenience.
27
+ *
28
+ * For an MCP host config, point straight at oam and skip this file:
29
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
30
+ *
31
31
  * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
32
32
  * `CADDY_MCP_SANDBOX=1` runs the server under oam's permission model.
33
33
  *
@@ -52,71 +52,74 @@
52
52
  * An older oam is not an error: the launcher falls back to Node and says so on
53
53
  * stderr. Pinning the floor here is what makes that fallback automatic.
54
54
  *
55
- * SELECTION
56
- * CADDY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
57
- * CADDY_MCP_RUNTIME=node never use oam
58
- * CADDY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
55
+ * SELECTION
56
+ * CADDY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
57
+ * CADDY_MCP_RUNTIME=node never use oam
58
+ * CADDY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
59
59
  * CADDY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
60
- * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
61
- */
62
-
63
- import { execFileSync, spawn } from "node:child_process";
64
- import { existsSync } from "node:fs";
65
- import { constants, homedir } from "node:os";
66
- import { delimiter, join } from "node:path";
60
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
61
+ */
62
+
63
+ import { execFileSync, spawn } from "node:child_process";
64
+ import { existsSync } from "node:fs";
65
+ import { constants, homedir } from "node:os";
66
+ import { delimiter, join } from "node:path";
67
67
  import { fileURLToPath } from "node:url";
68
68
 
69
69
  /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
70
- const OAM_MIN = [0, 9, 0];
71
-
72
- // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
73
- // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
74
- // in-process fallback must use the file:// URL. spawn() needs a real path.
75
- const SERVER_URL = new URL("../dist/index.js", import.meta.url);
76
- const SERVER_ENTRY = fileURLToPath(SERVER_URL);
77
- const isWin = process.platform === "win32";
78
- const exe = isWin ? "oam.exe" : "oam";
79
-
80
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
81
- function findOam() {
82
- // 1. Explicit override wins and is never second-guessed.
83
- const override = process.env.OAM_BIN;
84
- if (override) return existsSync(override) ? override : null;
85
-
86
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
87
- // usually has oam/target/release on PATH, and a build directory is the
88
- // wrong thing for a user-facing launcher to bind to: cargo replaces the
89
- // binary underneath running processes, and the dev build is not the
90
- // release the user installed. Preferring the installed copy makes the
91
- // default path "what a normal user has", and OAM_BIN remains the way to
92
- // point deliberately at a dev build.
93
- //
94
- // Both forms are checked on Windows: the installer defaults to
95
- // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
96
- // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
97
- // install.
98
- const installed = [join(homedir(), ".oam", "bin", exe)];
99
- if (isWin) {
100
- installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
101
- }
102
- for (const candidate of installed) {
103
- if (existsSync(candidate)) return candidate;
104
- }
105
-
106
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
107
- // would cost a subprocess on every launch just to decide whether to spawn.
108
- const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
109
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
110
- if (!dir) continue;
111
- for (const ext of isWin ? pathExt : [""]) {
112
- const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
113
- if (existsSync(candidate)) return candidate;
114
- }
115
- }
116
-
117
- return null;
118
- }
119
-
70
+ const OAM_MIN = [0, 9, 0];
71
+
72
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
73
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
74
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
75
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
76
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
77
+ const isWin = process.platform === "win32";
78
+ const exe = isWin ? "oam.exe" : "oam";
79
+
80
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
81
+ function findOam() {
82
+ // 1. Explicit override wins and is never second-guessed.
83
+ const override = process.env.OAM_BIN;
84
+ if (override) return existsSync(override) ? override : null;
85
+
86
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
87
+ // usually has oam/target/release on PATH, and a build directory is the
88
+ // wrong thing for a user-facing launcher to bind to: cargo replaces the
89
+ // binary underneath running processes, and the dev build is not the
90
+ // release the user installed. Preferring the installed copy makes the
91
+ // default path "what a normal user has", and OAM_BIN remains the way to
92
+ // point deliberately at a dev build.
93
+ //
94
+ // Both forms are checked on Windows: the installer defaults to
95
+ // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
96
+ // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
97
+ // install.
98
+ const installed = [join(homedir(), ".oam", "bin", exe)];
99
+ if (isWin) {
100
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
101
+ }
102
+ for (const candidate of installed) {
103
+ if (existsSync(candidate)) return candidate;
104
+ }
105
+
106
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
107
+ // would cost a subprocess on every launch just to decide whether to spawn.
108
+ // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
109
+ // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
110
+ // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
111
+ // the full PATHEXT list would hand back a path this launcher cannot execute.
112
+ // Discovery has to agree with execution. A skipped shim is still reported --
113
+ // see findOamShim.
114
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
115
+ if (!dir) continue;
116
+ const candidate = join(dir, exe);
117
+ if (existsSync(candidate)) return candidate;
118
+ }
119
+
120
+ return null;
121
+ }
122
+
120
123
  /**
121
124
  * `oam --version` -> [major, minor, patch], or null when it cannot be read.
122
125
  * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
@@ -183,87 +186,243 @@ function sandboxFlags() {
183
186
  return flags;
184
187
  }
185
188
 
186
- /** Run the server in THIS process. The zero-overhead fallback. */
187
- async function runInProcess() {
188
- // A server may gate its bootstrap on being the process ENTRY POINT --
189
- // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
190
- // test file can import the module for unit tests without connecting a stdio
191
- // transport. aws-mcp does exactly this. Importing the server here would leave
192
- // argv[1] pointing at THIS launcher, the guard would read false, and the
193
- // server would load but never serve: the MCP handshake just hangs.
194
- //
195
- // Point argv[1] at the server first, so the in-process path is
196
- // indistinguishable from having executed the file directly. The spawn path
197
- // needs no equivalent -- there argv[1] is already the server.
198
- process.argv[1] = SERVER_ENTRY;
199
- await import(SERVER_URL.href);
200
- }
201
-
202
- const mode = (process.env.CADDY_MCP_RUNTIME ?? "auto").toLowerCase();
203
-
204
- if (mode === "node") {
205
- await runInProcess();
206
- } else {
207
- const oam = findOam();
208
-
209
- if (!oam) {
210
- if (mode === "oam") {
211
- // Explicitly demanded, so this is a real misconfiguration. writeSync
212
- // because stderr is async for TTYs/pipes on Windows and process.exit
213
- // truncates pending writes.
214
- const { writeSync } = await import("node:fs");
215
- writeSync(
216
- 2,
217
- "caddy-mcp: CADDY_MCP_RUNTIME=oam but no oam binary was found.\n" +
218
- "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
219
- );
220
- process.exit(1);
221
- }
222
- await runInProcess();
223
- } else {
224
- // `--` separates oam's own flags from the script's argv, so `caddy-mcp
225
- // --version` and any host-supplied flags survive the hop unchanged.
226
- const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
227
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
228
- // stdin/stdout is untouched and the host's stdin-close still reaches the
229
- // server's shutdown path.
230
- stdio: "inherit",
231
- env: process.env,
232
- windowsHide: true,
233
- });
234
-
235
- // If oam cannot be executed at all (deleted between the stat and the spawn,
236
- // wrong arch, permission), fall back rather than failing the whole server.
237
- // `spawned` prevents falling back AFTER the child started, which would
238
- // double-start the server on the same stdio.
239
- let spawned = false;
240
- child.on("spawn", () => {
241
- spawned = true;
242
- });
243
- child.on("error", (err) => {
244
- if (spawned) return;
245
- if (mode === "oam") {
246
- process.stderr.write(`caddy-mcp: failed to launch oam (${err.message})\n`);
247
- process.exit(1);
248
- }
249
- void runInProcess();
250
- });
251
-
252
- // Forward termination so the server's own shutdown path runs in the child
253
- // rather than the child being orphaned. No-op on Windows, harmless to add.
254
- for (const sig of ["SIGINT", "SIGTERM"]) {
255
- process.on(sig, () => {
256
- if (!child.killed) child.kill(sig);
257
- });
258
- }
259
-
260
- child.on("exit", (code, signal) => {
261
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
262
- // conventional shell exit status rather than a bare 0.
263
- if (signal) {
264
- process.exit(128 + (constants.signals[signal] ?? 15));
265
- }
266
- process.exit(code ?? 0);
267
- });
268
- }
269
- }
189
+ /**
190
+ * Write a diagnostic to stderr synchronously, so a following process.exit
191
+ * cannot truncate it.
192
+ *
193
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
194
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
195
+ * there rather than blocking the write. Loop over the remaining bytes, and if
196
+ * stderr turns out to be unusable give up quietly -- failing to print a
197
+ * diagnostic is not worth crashing a stdio server over.
198
+ */
199
+ async function errSync(message) {
200
+ const { writeSync } = await import("node:fs");
201
+ const buf = Buffer.from(message);
202
+ let off = 0;
203
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
204
+ try {
205
+ off += writeSync(2, buf, off, buf.length - off);
206
+ } catch (err) {
207
+ if (err?.code !== "EAGAIN") return;
208
+ // Pipe is full and the reader has not drained yet -- retry.
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
215
+ * cannot spawn. Reported rather than ignored, because "no oam binary was found"
216
+ * reads as "install oam" -- the one thing that will not help. Windows only;
217
+ * there is no such shim concept on POSIX.
218
+ */
219
+ function findOamShim() {
220
+ if (!isWin) return null;
221
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
222
+ if (!dir) continue;
223
+ for (const ext of [".cmd", ".bat"]) {
224
+ const candidate = join(dir, `oam${ext}`);
225
+ if (existsSync(candidate)) return candidate;
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /** Run the server in THIS process. The zero-overhead fallback. */
232
+ async function runInProcess() {
233
+ // A server may gate its bootstrap on being the process ENTRY POINT --
234
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
235
+ // test file can import the module for unit tests without connecting a stdio
236
+ // transport. aws-mcp does exactly this. Importing the server here would leave
237
+ // argv[1] pointing at THIS launcher, the guard would read false, and the
238
+ // server would load but never serve: the MCP handshake just hangs.
239
+ //
240
+ // Point argv[1] at the server first, so the in-process path is
241
+ // indistinguishable from having executed the file directly. The spawn path
242
+ // needs no equivalent -- there argv[1] is already the server.
243
+ process.argv[1] = SERVER_ENTRY;
244
+ await import(SERVER_URL.href);
245
+ }
246
+
247
+ const mode = (process.env.CADDY_MCP_RUNTIME ?? "auto").toLowerCase();
248
+
249
+ if (mode === "node") {
250
+ await runInProcess();
251
+ } else {
252
+ const oam = findOam();
253
+ // Read the version ONCE, and only when discovery found something: the gate
254
+ // below has to tell "too old" apart from "could not be read at all", and
255
+ // re-probing inside the branch would cost a second subprocess.
256
+ //
257
+ // This is the first subprocess the launcher runs -- discovery itself is
258
+ // stat-only. Paid on every launch that finds an oam, including the ones
259
+ // that go on to fall back to Node.
260
+ const found = oam ? oamVersion(oam) : null;
261
+
262
+ if (!oam) {
263
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this
264
+ // launcher cannot spawn. Naming it turns "no oam binary was found" --
265
+ // which reads as "install oam", the one thing that will not help --
266
+ // into something the user can act on.
267
+ const oamShim = findOamShim();
268
+ const shimNote = oamShim
269
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
270
+ "Install the native oam binary, or point OAM_BIN at one.\n"
271
+ : "";
272
+ if (mode === "oam") {
273
+ // Explicitly demanded, so this is a real misconfiguration. writeSync
274
+ // because stderr is async for TTYs/pipes on Windows and process.exit
275
+ // truncates pending writes.
276
+ const { writeSync } = await import("node:fs");
277
+ writeSync(
278
+ 2,
279
+ "caddy-mcp: CADDY_MCP_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
280
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
281
+ );
282
+ process.exit(1);
283
+ }
284
+ // auto: falling back is correct, but silence is how someone never learns
285
+ // their oam install is a shape this launcher skips.
286
+ if (oamShim) await errSync(`caddy-mcp: ${shimNote}Using Node instead.\n`);
287
+ await runInProcess();
288
+ } else if (!atLeast(found, OAM_MIN)) {
289
+ const min = OAM_MIN.join(".");
290
+ // Two different causes reach this branch and they need different
291
+ // remedies. `found === null` is NOT "old": oamVersion returns null when
292
+ // the binary could not be run at all (not executable, wrong arch, a
293
+ // .cmd/.bat Node refuses, deleted between the stat and the probe) or
294
+ // when its --version output did not parse. Telling that user to
295
+ // `oam self-update` sends them after the one cause it definitely is not.
296
+ const detail = found
297
+ ? `${oam} is oam ${found.join(".")}, older than ${min}`
298
+ : `${oam} could not be run, or did not report a version this launcher understands`;
299
+ const remedy = found
300
+ ? "Run `oam self-update`, or use CADDY_MCP_RUNTIME=node.\n"
301
+ : "Check that it is an executable oam binary for this platform, or use CADDY_MCP_RUNTIME=node.\n";
302
+ if (mode === "oam") {
303
+ await errSync(`caddy-mcp: CADDY_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
304
+ process.exit(1);
305
+ }
306
+ // auto: neither cause is worth failing over -- prefer Node. Say so,
307
+ // because a silent downgrade is how someone keeps running an oam they
308
+ // meant to update, or never learns their oam is unexecutable.
309
+ await errSync(`caddy-mcp: ${detail}; using Node instead.\n`);
310
+ await runInProcess();
311
+ } else {
312
+ // `--` separates oam's own flags from the script's argv, so `caddy-mcp
313
+ // --version` and any host-supplied flags survive the hop unchanged.
314
+ // Every "oam could not be executed" outcome lands here: the synchronous
315
+ // throw from spawn() and the async 'error' event mean the same thing and
316
+ // must degrade the same way, so the handling lives in one place.
317
+ // errSync rather than process.stderr.write because stderr is async for
318
+ // TTYs and pipes on Windows and the process.exit below truncates pending
319
+ // writes.
320
+ const launchFailed = async (err) => {
321
+ if (mode === "oam") {
322
+ await errSync(`caddy-mcp: failed to launch oam (${err?.message ?? err})\n`);
323
+ process.exit(1);
324
+ }
325
+ await runInProcess();
326
+ };
327
+
328
+ // ONE reporter shared by both launchFailed call sites, so the sync-throw
329
+ // path and the 'error'-event path cannot drift apart. Either can reject:
330
+ // runInProcess() is a bare import() that rejects when dist/index.js is
331
+ // missing, and at ESM top level an unhandled rejection is an uncaught
332
+ // exception -- the exact failure this handling exists to prevent.
333
+ const fallbackFailed = (e) => {
334
+ process.stderr.write(`caddy-mcp: fallback to Node failed (${e?.message ?? e})\n`);
335
+ process.exitCode = 1;
336
+ };
337
+
338
+ let child = null;
339
+ try {
340
+ child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
341
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
342
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
343
+ // server's shutdown path.
344
+ stdio: "inherit",
345
+ env: process.env,
346
+ windowsHide: true,
347
+ });
348
+ } catch (err) {
349
+ // spawn() THROWS for some failures instead of emitting 'error', and the
350
+ // 'error' listener is registered AFTER this call, so it can never observe
351
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
352
+ // instead of falling back to Node.
353
+ await launchFailed(err).catch(fallbackFailed);
354
+ }
355
+
356
+ if (child) {
357
+
358
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
359
+ // wrong arch, permission), fall back rather than failing the whole server.
360
+ // `spawned` prevents falling back AFTER the child started, which would
361
+ // double-start the server on the same stdio.
362
+ let spawned = false;
363
+ child.on("spawn", () => {
364
+ spawned = true;
365
+ });
366
+ child.on("error", (err) => {
367
+ if (spawned) return;
368
+ // Handle the rejection instead of discarding it: a failing in-process
369
+ // fallback would otherwise escape as an unhandled rejection, replacing
370
+ // this launcher's diagnostic with a raw stack trace.
371
+ launchFailed(err).catch(fallbackFailed);
372
+ });
373
+
374
+ // Forward termination so the server's own shutdown path runs in the child
375
+ // rather than the child being orphaned.
376
+ //
377
+ // Registering ANY handler for these suppresses Node's default
378
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
379
+ // `child.killed` only records that kill() was CALLED, never that the child
380
+ // is gone, so gating on it swallows every signal after the first and wedges
381
+ // the launcher with no escape hatch.
382
+ //
383
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
384
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
385
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
386
+ // "a second signal" as impatience hard-kills a child that is already
387
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
388
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
389
+ // a wall-clock step cannot mis-gate the window either.
390
+ //
391
+ // POSIX vs Windows, and why we do NOT forward on Windows.
392
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
393
+ // is what lets the child run its shutdown. On Windows there are no POSIX
394
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
395
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
396
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
397
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
398
+ // child's process.on("exit") cleanup. The console has already notified the
399
+ // child, so on Windows the timer below is the only kill we issue.
400
+ const ESCALATE_AFTER_MS = 2000;
401
+ let escalation = null;
402
+ for (const sig of ["SIGINT", "SIGTERM"]) {
403
+ process.on(sig, () => {
404
+ // No try/catch: kill() on an already-exited child returns false, it does
405
+ // not throw. It throws only for a signal the platform does not know,
406
+ // which SIGINT/SIGTERM/SIGKILL never are.
407
+ if (!isWin) child.kill(sig);
408
+ if (escalation) return; // already counting down; further signals are noise
409
+ escalation = setTimeout(() => {
410
+ // Still here after its grace window. Stop waiting on it.
411
+ child.kill("SIGKILL");
412
+ process.exit(128 + (constants.signals[sig] ?? 15));
413
+ }, ESCALATE_AFTER_MS);
414
+ });
415
+ }
416
+
417
+ child.on("exit", (code, signal) => {
418
+ if (escalation) clearTimeout(escalation);
419
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
420
+ // conventional shell exit status rather than a bare 0.
421
+ if (signal) {
422
+ process.exit(128 + (constants.signals[signal] ?? 15));
423
+ }
424
+ process.exit(code ?? 0);
425
+ });
426
+ }
427
+ }
428
+ }
package/package.json CHANGED
@@ -1,82 +1,82 @@
1
- {
2
- "name": "@yawlabs/caddy-mcp",
3
- "version": "2.3.0",
4
- "mcpName": "io.github.YawLabs/caddy-mcp",
5
- "description": "MCP server for managing Caddy web servers via the admin API",
6
- "license": "MIT",
7
- "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
8
- "type": "module",
9
- "exports": {
10
- ".": {
11
- "import": "./dist/server.js",
12
- "types": "./dist/server.d.ts"
13
- }
14
- },
15
- "main": "./dist/server.js",
16
- "types": "./dist/server.d.ts",
17
- "bin": {
18
- "caddy-mcp": "bin/caddy-mcp.mjs"
19
- },
20
- "files": [
21
- "bin/caddy-mcp.mjs",
22
- "dist",
23
- "!dist/**/*.test.*",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "scripts": {
28
- "build": "tsup && tsc -p tsconfig.build.json",
29
- "dev": "tsup --watch",
30
- "test": "vitest run",
31
- "lint": "biome check src/",
32
- "lint:fix": "biome check --write src/",
33
- "typecheck": "node scripts/typecheck.mjs",
34
- "typecheck:tsc": "tsc --noEmit",
35
- "test:ci": "npm run build && npm test",
36
- "prepublishOnly": "npm run build",
37
- "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
38
- "start": "node dist/index.js"
39
- },
40
- "dependencies": {
41
- "@modelcontextprotocol/sdk": "^1.29.0",
42
- "zod": "^4.3.6"
43
- },
44
- "overrides": {
45
- "hono": "^4.12.21",
46
- "@hono/node-server": "^1.19.13",
47
- "postcss": "^8.5.10",
48
- "ip-address": "^10.1.1",
49
- "fast-uri": "^3.1.2",
50
- "qs": "^6.15.2",
51
- "esbuild": "^0.28.1"
52
- },
53
- "devDependencies": {
54
- "@biomejs/biome": "~2.4.11",
55
- "@types/node": "^26.0.0",
56
- "postject": "^1.0.0-alpha.6",
57
- "tsup": "^8.4.0",
58
- "typescript": "^7.0.2",
59
- "vitest": "^4.1.4"
60
- },
61
- "engines": {
62
- "node": ">=20"
63
- },
64
- "keywords": [
65
- "mcp",
66
- "model-context-protocol",
67
- "caddy",
68
- "reverse-proxy",
69
- "web-server",
70
- "mcp-server",
71
- "devtools",
72
- "ai"
73
- ],
74
- "repository": {
75
- "type": "git",
76
- "url": "git+https://github.com/YawLabs/caddy-mcp.git"
77
- },
78
- "bugs": {
79
- "url": "https://github.com/YawLabs/caddy-mcp/issues"
80
- },
81
- "homepage": "https://github.com/YawLabs/caddy-mcp"
82
- }
1
+ {
2
+ "name": "@yawlabs/caddy-mcp",
3
+ "version": "2.3.1",
4
+ "mcpName": "io.github.YawLabs/caddy-mcp",
5
+ "description": "MCP server for managing Caddy web servers via the admin API",
6
+ "license": "MIT",
7
+ "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
8
+ "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/server.js",
12
+ "types": "./dist/server.d.ts"
13
+ }
14
+ },
15
+ "main": "./dist/server.js",
16
+ "types": "./dist/server.d.ts",
17
+ "bin": {
18
+ "caddy-mcp": "bin/caddy-mcp.mjs"
19
+ },
20
+ "files": [
21
+ "bin/caddy-mcp.mjs",
22
+ "dist",
23
+ "!dist/**/*.test.*",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsup && tsc -p tsconfig.build.json",
29
+ "dev": "tsup --watch",
30
+ "test": "vitest run",
31
+ "lint": "biome check src/",
32
+ "lint:fix": "biome check --write src/",
33
+ "typecheck": "node scripts/typecheck.mjs",
34
+ "typecheck:tsc": "tsc --noEmit",
35
+ "test:ci": "npm run build && npm test",
36
+ "prepublishOnly": "npm run build",
37
+ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
38
+ "start": "node dist/index.js"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0",
42
+ "zod": "^4.3.6"
43
+ },
44
+ "overrides": {
45
+ "hono": "^4.12.21",
46
+ "@hono/node-server": "^1.19.13",
47
+ "postcss": "^8.5.10",
48
+ "ip-address": "^10.1.1",
49
+ "fast-uri": "^3.1.2",
50
+ "qs": "^6.15.2",
51
+ "esbuild": "^0.28.1"
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "~2.4.11",
55
+ "@types/node": "^26.0.0",
56
+ "postject": "^1.0.0-alpha.6",
57
+ "tsup": "^8.4.0",
58
+ "typescript": "^7.0.2",
59
+ "vitest": "^4.1.4"
60
+ },
61
+ "engines": {
62
+ "node": ">=20"
63
+ },
64
+ "keywords": [
65
+ "mcp",
66
+ "model-context-protocol",
67
+ "caddy",
68
+ "reverse-proxy",
69
+ "web-server",
70
+ "mcp-server",
71
+ "devtools",
72
+ "ai"
73
+ ],
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/YawLabs/caddy-mcp.git"
77
+ },
78
+ "bugs": {
79
+ "url": "https://github.com/YawLabs/caddy-mcp/issues"
80
+ },
81
+ "homepage": "https://github.com/YawLabs/caddy-mcp"
82
+ }