@yawlabs/caddy-mcp 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,115 +2,221 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
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
+ [![CI](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
5
7
 
6
- **MCP server for managing Caddy web servers.** 16 tools for config management, reverse proxy setup, route operations, TLS, and server monitoring all via Caddy's admin API.
8
+ **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 17 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics.
7
9
 
8
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
9
11
 
10
- ## Quick start
12
+ [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](https://mcp.hosting/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&env=CADDY_ADMIN_URL%2CCADDY_API_TOKEN&description=Manage%20Caddy%20web%20servers%20-%20config%2C%20routes%2C%20TLS%2C%20PKI&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fcaddy-mcp)
11
13
 
12
- ```bash
13
- npx @yawlabs/caddy-mcp
14
- ```
14
+ One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
15
15
 
16
- Or install globally:
16
+ ## Why this one?
17
17
 
18
- ```bash
19
- npm install -g @yawlabs/caddy-mcp
20
- caddy-mcp
21
- ```
18
+ Other Caddy MCP servers wrap half the admin API and silently swallow errors. This one doesn't.
22
19
 
23
- ## MCP client configuration
20
+ - **Complete admin API coverage** — every documented endpoint: `/load`, `/config/*`, `/id/*`, `/stop`, `/adapt`, `/pki/ca/*`, `/reverse_proxy/upstreams`, `/metrics`. No placeholder tools that 404.
21
+ - **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.
22
+ - **Safe-by-default mutations** — `caddy_config_set` defaults to idempotent `overwrite` (PATCH), not `append` (POST). Calling twice doesn't duplicate your route.
23
+ - **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.
24
+ - **No leaked credentials in errors** — if `CADDY_ADMIN_URL` contains a token in the path/query, the connect-failed message shows only the origin.
25
+ - **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.
26
+ - **Tool annotations** — every tool declares `readOnlyHint`, `destructiveHint`, and `idempotentHint`, so MCP clients can skip confirmations for safe ops.
27
+ - **Instant startup** — ships as a single bundle with two runtime deps (the MCP SDK + Zod). No 5-minute `node_modules` install.
28
+ - **Input hardening** — adapter names, `@id` values, server names, and CA ids are all regex-validated with length caps. Blocks CRLF header injection and ReDoS.
24
29
 
25
- ### Claude Code
30
+ ## Quick start
26
31
 
27
- ```bash
28
- claude mcp add caddy-mcp npx @yawlabs/caddy-mcp
29
- ```
32
+ **1. Enable the Caddy admin API**
33
+
34
+ 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`.
30
35
 
31
- ### Claude Desktop / Cursor / Windsurf
36
+ **2. Create `.mcp.json` in your project root**
32
37
 
33
- Add to your MCP config file:
38
+ macOS / Linux / WSL:
34
39
 
35
40
  ```json
36
41
  {
37
42
  "mcpServers": {
38
- "caddy-mcp": {
43
+ "caddy": {
39
44
  "command": "npx",
40
- "args": ["@yawlabs/caddy-mcp"],
41
- "env": {
42
- "CADDY_ADMIN_URL": "http://localhost:2019"
43
- }
45
+ "args": ["-y", "@yawlabs/caddy-mcp"]
44
46
  }
45
47
  }
46
48
  }
47
49
  ```
48
50
 
51
+ Windows:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "caddy": {
57
+ "command": "cmd",
58
+ "args": ["/c", "npx", "-y", "@yawlabs/caddy-mcp"]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ > **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.
65
+
66
+ **3. Restart and approve**
67
+
68
+ Restart Claude Code (or your MCP client) and approve the Caddy MCP server when prompted.
69
+
70
+ That's it. Now ask your AI assistant:
71
+
72
+ > "Proxy api.local to localhost:3000"
73
+ >
74
+ > "What routes are configured on srv0?"
75
+ >
76
+ > "Show me the Prometheus metrics"
77
+
49
78
  ## Configuration
50
79
 
51
- | Environment Variable | Default | Description |
80
+ | Environment variable | Default | Description |
52
81
  |---|---|---|
53
- | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL |
54
- | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints |
82
+ | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
83
+ | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
84
+
85
+ **Alternate MCP clients:**
86
+
87
+ | Client | Config file |
88
+ |---|---|
89
+ | Claude Code | `.mcp.json` (project root) or `~/.claude.json` (global) |
90
+ | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
91
+ | Cursor | `~/.cursor/mcp.json` |
92
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
93
+ | VS Code | `.vscode/mcp.json` |
94
+
95
+ Use the same JSON block shown above in any of these.
55
96
 
56
97
  ## Tools
57
98
 
58
- ### Config management
99
+ ### Config management (5)
59
100
 
60
- - **caddy_config_get** — Read config at any JSON path (or full config)
61
- - **caddy_config_set** — Create or replace config at a path
62
- - **caddy_config_delete** — Delete config at a path
63
- - **caddy_config_by_id** — Read, update, or delete config by `@id` tag
64
- - **caddy_load** — Replace entire config atomically
101
+ - **caddy_config_get** — Read config at any JSON path (or the full config).
102
+ - **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
103
+ - **caddy_config_delete** — Delete config at a path.
104
+ - **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths.
105
+ - **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning.
65
106
 
66
- ### Route operations
107
+ ### Route operations (4)
67
108
 
68
- - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`
69
- - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler)
70
- - **caddy_list_routes** — Human-readable route summary
109
+ - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`.
110
+ - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
111
+ - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
112
+ - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
71
113
 
72
- ### TLS & config conversion
114
+ ### TLS & config conversion (2)
73
115
 
74
- - **caddy_tls** — Check/configure TLS settings, ACME email, CA
75
- - **caddy_adapt** — Convert Caddyfile to JSON (preview before applying)
116
+ - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. Falls back gracefully when paths don't yet exist.
117
+ - **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
76
118
 
77
- ### Server operations
119
+ ### Server operations (6)
78
120
 
79
- - **caddy_status** — Connectivity check + config summary
80
- - **caddy_list_servers** — List all HTTP servers with names, addresses, and TLS status
81
- - **caddy_upstreams** — Reverse proxy backend health
82
- - **caddy_metrics** — Prometheus metrics (request counts, durations, connections)
83
- - **caddy_pki** — CA info and certificate chains
84
- - **caddy_stop** — Graceful shutdown (requires confirmation)
121
+ - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
122
+ - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
123
+ - **caddy_upstreams** — Reverse proxy backend health.
124
+ - **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes).
125
+ - **caddy_pki** — CA info and certificate chains (default CA: `local`).
126
+ - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
85
127
 
86
128
  ## Resources
87
129
 
88
- - `caddy://config`Current Caddy JSON configuration
89
- - `caddy://upstreams` — Reverse proxy upstream health status
130
+ Browsable read-only dataMCP clients can fetch these directly without a tool call:
131
+
132
+ - `caddy://config` — Current full Caddy JSON configuration.
133
+ - `caddy://servers` — Summary of all configured HTTP servers.
134
+ - `caddy://upstreams` — Reverse proxy upstream health status.
135
+ - `caddy://metrics` — Prometheus metrics (text exposition format).
90
136
 
91
137
  ## Examples
92
138
 
139
+ ### Add a reverse proxy
140
+
141
+ ```
142
+ > "Proxy api.example.com to my app on port 3000"
143
+ → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
144
+ ```
145
+
146
+ ### Preview a Caddyfile before applying it
147
+
148
+ ```
149
+ > "Convert this Caddyfile to JSON so I can review it:
150
+ example.com {
151
+ reverse_proxy localhost:8080
152
+ }"
153
+ → caddy_adapt({ config: "..." })
154
+ ```
155
+
156
+ ### Diagnose slow routes
157
+
158
+ ```
159
+ > "Fetch Prometheus metrics and tell me which route is slowest"
160
+ → caddy_metrics()
93
161
  ```
94
- > "Proxy api.local to my dev server on port 3000"
95
- → caddy_reverse_proxy(from: "api.local", to: ["localhost:3000"])
96
162
 
97
- > "What routes are configured?"
98
- → caddy_list_routes()
163
+ ### Safely update a route by @id
99
164
 
100
- > "Show me the full Caddy config"
101
- caddy_config_get()
165
+ ```
166
+ > "Update the route with @id 'api-v2' to point to the new backend"
167
+ → caddy_config_by_id({ id: "api-v2", action: "set", value: {...} })
168
+ # Uses ETags — you'll get HTTP 412 if someone else changed it first
169
+ ```
102
170
 
103
- > "Convert this Caddyfile to JSON"
104
- → caddy_adapt(config: "example.com {\n reverse_proxy localhost:8080\n}")
171
+ ### Atomic deploy
105
172
 
106
- > "Is Caddy running?"
107
- → caddy_status()
108
173
  ```
174
+ > "Replace the whole config with this Caddyfile"
175
+ → caddy_adapt({ config: "..." }) # validate first
176
+ → caddy_load({ config: adaptedJson }) # apply atomically
177
+ ```
178
+
179
+ ## Troubleshooting
180
+
181
+ **"Cannot connect to Caddy admin API"**
182
+
183
+ - Make sure Caddy is running. `caddy run` or `systemctl status caddy`.
184
+ - Check the admin endpoint. Default is `http://localhost:2019`. If Caddy is in Docker, use the container hostname.
185
+ - Set `CADDY_ADMIN_URL` in your MCP config `env` to match.
186
+
187
+ **"HTTP 412 Precondition Failed"**
188
+
189
+ - Someone (or something) changed the config between your read and your write.
190
+ - The cached ETag has been invalidated. Re-read the config and retry.
191
+
192
+ **"HTTP 403" on /load or /config writes**
193
+
194
+ - You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
195
+ - Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
196
+
197
+ **Windows: MCP server doesn't start**
198
+
199
+ - Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
109
200
 
110
201
  ## Requirements
111
202
 
112
203
  - Node.js 18+
113
- - Caddy server with admin API enabled (default: `localhost:2019`)
204
+ - Caddy 2.x with admin API enabled (default: `localhost:2019`)
205
+
206
+ ## Contributing
207
+
208
+ ```bash
209
+ git clone https://github.com/YawLabs/caddy-mcp.git
210
+ cd caddy-mcp
211
+ npm install
212
+ npm run lint # Biome check
213
+ npm run lint:fix # Auto-fix
214
+ npm run build # tsup bundle
215
+ npm test # Vitest (83 tests)
216
+ npm run typecheck # tsc --noEmit
217
+ ```
218
+
219
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release process.
114
220
 
115
221
  ## License
116
222
 
package/dist/index.js CHANGED
@@ -9,6 +9,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
  var DEFAULT_URL = "http://localhost:2019";
10
10
  var TIMEOUT = 1e4;
11
11
  var etagCache = /* @__PURE__ */ new Map();
12
+ var MAX_ETAG_CACHE = 256;
13
+ function setEtag(path, etag) {
14
+ if (etagCache.size >= MAX_ETAG_CACHE && !etagCache.has(path)) {
15
+ const oldest = etagCache.keys().next().value;
16
+ if (oldest !== void 0) etagCache.delete(oldest);
17
+ }
18
+ etagCache.set(path, etag);
19
+ }
12
20
  function getBaseUrl() {
13
21
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
14
22
  }
@@ -43,7 +51,7 @@ async function caddyRequest(method, path, body, contentType, timeout) {
43
51
  const text = await res.text();
44
52
  const etag = res.headers.get("ETag") || void 0;
45
53
  if (method === "GET" && etag && isConfigPath) {
46
- etagCache.set(path, etag);
54
+ setEtag(path, etag);
47
55
  }
48
56
  if (isWrite && res.ok && isConfigPath) {
49
57
  etagCache.delete(path);
@@ -68,10 +76,16 @@ async function caddyRequest(method, path, body, contentType, timeout) {
68
76
  } catch (err) {
69
77
  const msg = err instanceof Error ? err.message : String(err);
70
78
  if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
79
+ const baseUrl = getBaseUrl();
80
+ let origin = baseUrl;
81
+ try {
82
+ origin = new URL(baseUrl).origin;
83
+ } catch {
84
+ }
71
85
  return {
72
86
  ok: false,
73
87
  status: 0,
74
- error: `Cannot connect to Caddy admin API at ${getBaseUrl()} \u2014 is Caddy running?`
88
+ error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
75
89
  };
76
90
  }
77
91
  if (msg.includes("abort") || msg.includes("timeout")) {
@@ -168,6 +182,40 @@ function registerResources(server) {
168
182
  };
169
183
  }
170
184
  );
185
+ server.resource(
186
+ "caddy-metrics",
187
+ "caddy://metrics",
188
+ { description: "Prometheus metrics (text exposition format)" },
189
+ async () => {
190
+ const res = await getMetrics();
191
+ return {
192
+ contents: [
193
+ {
194
+ uri: "caddy://metrics",
195
+ mimeType: "text/plain",
196
+ text: res.ok ? String(res.data ?? "") : `Error: ${res.error}`
197
+ }
198
+ ]
199
+ };
200
+ }
201
+ );
202
+ server.resource(
203
+ "caddy-servers",
204
+ "caddy://servers",
205
+ { description: "Summary of all configured HTTP servers" },
206
+ async () => {
207
+ const res = await configGet("apps/http/servers");
208
+ return {
209
+ contents: [
210
+ {
211
+ uri: "caddy://servers",
212
+ mimeType: "application/json",
213
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
214
+ }
215
+ ]
216
+ };
217
+ }
218
+ );
171
219
  }
172
220
 
173
221
  // src/tools/adapt.ts
@@ -193,7 +241,7 @@ function registerAdaptTools(server) {
193
241
  "Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces. Returns the adapted JSON and any warnings separately.",
194
242
  {
195
243
  config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
196
- adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
244
+ adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
197
245
  },
198
246
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
199
247
  async ({ config, adapter }) => {
@@ -230,12 +278,12 @@ function registerConfigTools(server) {
230
278
  );
231
279
  server.tool(
232
280
  "caddy_config_set",
233
- "Write config at a JSON path. Mode 'append' (default) adds to arrays or creates keys (POST). Mode 'overwrite' replaces existing values (PATCH). Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
281
+ "Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
234
282
  {
235
283
  path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
236
284
  value: z2.any().describe("The JSON value to set at the path"),
237
- mode: z2.enum(["append", "overwrite", "insert"]).optional().default("append").describe(
238
- "'append' = POST (add to arrays, create on objects), 'overwrite' = PATCH (replace existing), 'insert' = PUT (insert at array index)"
285
+ mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
286
+ "'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
239
287
  )
240
288
  },
241
289
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -268,13 +316,16 @@ function registerConfigTools(server) {
268
316
  "caddy_config_by_id",
269
317
  "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.",
270
318
  {
271
- id: z2.string().regex(/^[\w-]+$/).describe("The @id value of the config object"),
319
+ id: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
272
320
  action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
273
321
  value: z2.any().optional().describe("New value (required for 'set' action)"),
274
- subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object")
322
+ subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
323
+ mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
324
+ "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
325
+ )
275
326
  },
276
327
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
277
- async ({ id, action, value, subpath }) => {
328
+ async ({ id, action, value, subpath, mode }) => {
278
329
  if (action === "get") {
279
330
  return formatResult(await configByIdGet(id, subpath));
280
331
  }
@@ -285,7 +336,8 @@ function registerConfigTools(server) {
285
336
  content: [{ type: "text", text: "Error: value is required for 'set' action" }]
286
337
  };
287
338
  }
288
- return formatResult(await configByIdSet(id, value, "PATCH", subpath));
339
+ const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
340
+ return formatResult(await configByIdSet(id, value, method, subpath));
289
341
  }
290
342
  if (action === "delete") {
291
343
  return formatResult(await configByIdDelete(id, subpath));
@@ -375,7 +427,7 @@ ${lines.join("\n")}` }]
375
427
  "caddy_pki",
376
428
  "Get PKI certificate authority info or the CA certificate chain.",
377
429
  {
378
- ca: z3.string().regex(/^[\w-]+$/).optional().default("local").describe("CA ID (default: 'local')"),
430
+ ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
379
431
  certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
380
432
  },
381
433
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -410,6 +462,10 @@ ${lines.join("\n")}` }]
410
462
 
411
463
  // src/tools/routes.ts
412
464
  import { z as z4 } from "zod";
465
+ function safeJoin(value) {
466
+ if (!Array.isArray(value)) return "";
467
+ return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
468
+ }
413
469
  function parseFrom(from) {
414
470
  const cleaned = from.replace(/^https?:\/\//, "");
415
471
  const match = {};
@@ -445,7 +501,7 @@ function registerRouteTools(server) {
445
501
  {
446
502
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
447
503
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
448
- server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
504
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
449
505
  },
450
506
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
451
507
  async ({ from, to, server: srv }) => {
@@ -477,7 +533,7 @@ function registerRouteTools(server) {
477
533
  {
478
534
  match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
479
535
  handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
480
- server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
536
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
481
537
  terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
482
538
  },
483
539
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -494,38 +550,58 @@ function registerRouteTools(server) {
494
550
  "caddy_list_routes",
495
551
  "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
496
552
  {
497
- server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
553
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
498
554
  },
499
555
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
500
556
  async ({ server: srv }) => {
501
557
  const serverRes = await configGet(`apps/http/servers/${srv}`);
502
558
  if (!serverRes.ok) return formatResult(serverRes);
503
- const serverConfig = serverRes.data;
504
- const routes = serverConfig?.routes || [];
505
- const listen = serverConfig?.listen || [];
559
+ const serverConfig = serverRes.data || {};
560
+ const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
561
+ const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
562
+ const listenStr = listen.map(String).join(", ") || "default";
506
563
  if (routes.length === 0) {
507
564
  return {
508
565
  content: [
509
566
  {
510
567
  type: "text",
511
- text: `Server ${srv} (listen: ${listen.join(", ") || "default"}) \u2014 no routes configured`
568
+ text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
512
569
  }
513
570
  ]
514
571
  };
515
572
  }
516
- const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
573
+ const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
517
574
  for (let i = 0; i < routes.length; i++) {
518
- const route = routes[i];
519
- const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
520
- const group = route.group ? ` group="${route.group}"` : "";
521
- const matchers = (route.match || []).map((m) => {
575
+ const rawRoute = routes[i];
576
+ if (!rawRoute || typeof rawRoute !== "object") {
577
+ lines.push(` Route ${i}: <invalid>`);
578
+ continue;
579
+ }
580
+ const route = rawRoute;
581
+ const idVal = typeof route["@id"] === "string" ? route["@id"] : void 0;
582
+ const groupVal = typeof route.group === "string" ? route.group : void 0;
583
+ const id = idVal ? ` @id="${idVal}"` : "";
584
+ const group = groupVal ? ` group="${groupVal}"` : "";
585
+ const matchList = Array.isArray(route.match) ? route.match : [];
586
+ const matchers = matchList.map((rawMatcher) => {
587
+ if (!rawMatcher || typeof rawMatcher !== "object") return "catch-all";
588
+ const m = rawMatcher;
522
589
  const parts = [];
523
- if (m.host) parts.push(`host=[${m.host.join(",")}]`);
524
- if (m.path) parts.push(`path=[${m.path.join(",")}]`);
525
- if (m.method) parts.push(`method=[${m.method.join(",")}]`);
526
- if (m.protocol) parts.push(`protocol=${m.protocol}`);
527
- if (m.remote_ip) parts.push(`remote_ip=[${m.remote_ip.ranges?.join(",") || "..."}]`);
528
- if (m.client_ip) parts.push(`client_ip=[${m.client_ip.ranges?.join(",") || "..."}]`);
590
+ const host = safeJoin(m.host);
591
+ if (host) parts.push(`host=[${host}]`);
592
+ const path = safeJoin(m.path);
593
+ if (path) parts.push(`path=[${path}]`);
594
+ const method = safeJoin(m.method);
595
+ if (method) parts.push(`method=[${method}]`);
596
+ if (typeof m.protocol === "string") parts.push(`protocol=${m.protocol}`);
597
+ if (m.remote_ip && typeof m.remote_ip === "object") {
598
+ const ranges = safeJoin(m.remote_ip.ranges);
599
+ parts.push(`remote_ip=[${ranges || "..."}]`);
600
+ }
601
+ if (m.client_ip && typeof m.client_ip === "object") {
602
+ const ranges = safeJoin(m.client_ip.ranges);
603
+ parts.push(`client_ip=[${ranges || "..."}]`);
604
+ }
529
605
  if (m.query) parts.push("query=...");
530
606
  if (m.header) parts.push("header=...");
531
607
  if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
@@ -548,23 +624,51 @@ function registerRouteTools(server) {
548
624
  if (parts.length === 0) return "catch-all";
549
625
  return parts.join(" ");
550
626
  }).join(" | ");
551
- const handlers = (route.handle || []).map((h) => {
627
+ const handleList = Array.isArray(route.handle) ? route.handle : [];
628
+ const handlers = handleList.map((rawHandler) => {
629
+ if (!rawHandler || typeof rawHandler !== "object") return "unknown";
630
+ const h = rawHandler;
552
631
  if (h.handler === "reverse_proxy") {
553
- const upstreams = (h.upstreams || []).map((u) => u.dial).join(",");
632
+ const upstreamsArr = Array.isArray(h.upstreams) ? h.upstreams : [];
633
+ const upstreams = upstreamsArr.map((u) => {
634
+ if (u && typeof u === "object") {
635
+ const dial = u.dial;
636
+ if (typeof dial === "string") return dial;
637
+ }
638
+ return "?";
639
+ }).join(",");
554
640
  return `reverse_proxy(${upstreams})`;
555
641
  }
556
- if (h.handler === "file_server") return `file_server(${h.root || "."})`;
557
- if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
558
- if (h.handler === "rewrite") return `rewrite(${h.uri || "..."})`;
559
- if (h.handler === "subroute") return `subroute(${h.routes?.length || 0} routes)`;
642
+ if (h.handler === "file_server") {
643
+ const root = typeof h.root === "string" ? h.root : ".";
644
+ return `file_server(${root})`;
645
+ }
646
+ if (h.handler === "static_response") {
647
+ const status = typeof h.status_code === "number" ? h.status_code : 200;
648
+ return `static_response(${status})`;
649
+ }
650
+ if (h.handler === "rewrite") {
651
+ const uri = typeof h.uri === "string" ? h.uri : "...";
652
+ return `rewrite(${uri})`;
653
+ }
654
+ if (h.handler === "subroute") {
655
+ const count = Array.isArray(h.routes) ? h.routes.length : 0;
656
+ return `subroute(${count} routes)`;
657
+ }
560
658
  if (h.handler === "encode") return "encode";
561
659
  if (h.handler === "headers") return "headers";
562
- if (h.handler === "authentication")
563
- return `auth(${h.providers ? Object.keys(h.providers).join(",") : "..."})`;
564
- if (h.handler === "error") return `error(${h.status_code || "..."})`;
565
- return h.handler || "unknown";
660
+ if (h.handler === "authentication") {
661
+ const providers = h.providers && typeof h.providers === "object" ? Object.keys(h.providers).join(",") : "...";
662
+ return `auth(${providers})`;
663
+ }
664
+ if (h.handler === "error") {
665
+ const status = typeof h.status_code === "number" ? h.status_code : "...";
666
+ return `error(${status})`;
667
+ }
668
+ return typeof h.handler === "string" ? h.handler : "unknown";
566
669
  }).join(" \u2192 ");
567
- lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
670
+ const terminal = route.terminal === true ? " [terminal]" : "";
671
+ lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
568
672
  }
569
673
  return {
570
674
  content: [
@@ -574,6 +678,67 @@ function registerRouteTools(server) {
574
678
  };
575
679
  }
576
680
  );
681
+ server.tool(
682
+ "caddy_remove_route",
683
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
684
+ {
685
+ id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
686
+ index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
687
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
688
+ confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
689
+ },
690
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
691
+ async ({ id, index, server: srv, confirm }) => {
692
+ if (!id && index === void 0) {
693
+ return {
694
+ isError: true,
695
+ content: [{ type: "text", text: "Error: must provide either id or index" }]
696
+ };
697
+ }
698
+ if (!confirm) {
699
+ const target = id ? `@id="${id}"` : `route ${index} on server "${srv}"`;
700
+ return {
701
+ isError: true,
702
+ content: [
703
+ {
704
+ type: "text",
705
+ text: `Refusing to remove ${target} without confirm=true. Re-run with confirm:true to proceed.`
706
+ }
707
+ ]
708
+ };
709
+ }
710
+ if (id) {
711
+ const res2 = await configByIdDelete(id);
712
+ if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
713
+ return formatResult(res2);
714
+ }
715
+ const readRes = await configGet(`apps/http/servers/${srv}/routes`);
716
+ if (!readRes.ok) return formatResult(readRes);
717
+ const routes = readRes.data;
718
+ if (!Array.isArray(routes)) {
719
+ return {
720
+ isError: true,
721
+ content: [{ type: "text", text: `Error: server "${srv}" has no routes array (or it is malformed)` }]
722
+ };
723
+ }
724
+ if (index >= routes.length) {
725
+ return {
726
+ isError: true,
727
+ content: [
728
+ {
729
+ type: "text",
730
+ text: `Error: index ${index} out of range \u2014 server "${srv}" has ${routes.length} route(s)`
731
+ }
732
+ ]
733
+ };
734
+ }
735
+ const res = await configDelete(`apps/http/servers/${srv}/routes/${index}`);
736
+ if (res.ok) {
737
+ return { content: [{ type: "text", text: `Route ${index} removed from server "${srv}".` }] };
738
+ }
739
+ return formatResult(res);
740
+ }
741
+ );
577
742
  }
578
743
 
579
744
  // src/tools/tls.ts
@@ -588,6 +753,21 @@ function buildTlsConfig(fields) {
588
753
  }
589
754
  };
590
755
  }
756
+ function bothErrors(label, patchRes, postRes) {
757
+ const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
758
+ const postErr = postRes.error || `HTTP ${postRes.status}`;
759
+ return {
760
+ isError: true,
761
+ content: [
762
+ {
763
+ type: "text",
764
+ text: `Error: Failed to set ${label}.
765
+ PATCH attempt: ${patchErr}
766
+ POST fallback: ${postErr}`
767
+ }
768
+ ]
769
+ };
770
+ }
591
771
  function registerTlsTools(server) {
592
772
  server.tool(
593
773
  "caddy_tls",
@@ -608,11 +788,11 @@ function registerTlsTools(server) {
608
788
  isError: true,
609
789
  content: [{ type: "text", text: "Error: email is required for set_email action" }]
610
790
  };
611
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
612
- if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
613
- const fallback = await configPost("apps/tls", buildTlsConfig({ email }));
614
- if (fallback.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
615
- return formatResult(fallback);
791
+ const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
792
+ if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
793
+ const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
794
+ if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
795
+ return bothErrors("ACME email", patchRes, postRes);
616
796
  }
617
797
  if (action === "set_acme_ca") {
618
798
  if (!ca)
@@ -620,11 +800,11 @@ function registerTlsTools(server) {
620
800
  isError: true,
621
801
  content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
622
802
  };
623
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
624
- if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
625
- const fallback = await configPost("apps/tls", buildTlsConfig({ ca }));
626
- if (fallback.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
627
- return formatResult(fallback);
803
+ const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
804
+ if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
805
+ const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
806
+ if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
807
+ return bothErrors("ACME CA", patchRes, postRes);
628
808
  }
629
809
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
630
810
  }
package/dist/server.js CHANGED
@@ -7,6 +7,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  var DEFAULT_URL = "http://localhost:2019";
8
8
  var TIMEOUT = 1e4;
9
9
  var etagCache = /* @__PURE__ */ new Map();
10
+ var MAX_ETAG_CACHE = 256;
11
+ function setEtag(path, etag) {
12
+ if (etagCache.size >= MAX_ETAG_CACHE && !etagCache.has(path)) {
13
+ const oldest = etagCache.keys().next().value;
14
+ if (oldest !== void 0) etagCache.delete(oldest);
15
+ }
16
+ etagCache.set(path, etag);
17
+ }
10
18
  function getBaseUrl() {
11
19
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
12
20
  }
@@ -41,7 +49,7 @@ async function caddyRequest(method, path, body, contentType, timeout) {
41
49
  const text = await res.text();
42
50
  const etag = res.headers.get("ETag") || void 0;
43
51
  if (method === "GET" && etag && isConfigPath) {
44
- etagCache.set(path, etag);
52
+ setEtag(path, etag);
45
53
  }
46
54
  if (isWrite && res.ok && isConfigPath) {
47
55
  etagCache.delete(path);
@@ -66,10 +74,16 @@ async function caddyRequest(method, path, body, contentType, timeout) {
66
74
  } catch (err) {
67
75
  const msg = err instanceof Error ? err.message : String(err);
68
76
  if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
77
+ const baseUrl = getBaseUrl();
78
+ let origin = baseUrl;
79
+ try {
80
+ origin = new URL(baseUrl).origin;
81
+ } catch {
82
+ }
69
83
  return {
70
84
  ok: false,
71
85
  status: 0,
72
- error: `Cannot connect to Caddy admin API at ${getBaseUrl()} \u2014 is Caddy running?`
86
+ error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
73
87
  };
74
88
  }
75
89
  if (msg.includes("abort") || msg.includes("timeout")) {
@@ -166,6 +180,40 @@ function registerResources(server) {
166
180
  };
167
181
  }
168
182
  );
183
+ server.resource(
184
+ "caddy-metrics",
185
+ "caddy://metrics",
186
+ { description: "Prometheus metrics (text exposition format)" },
187
+ async () => {
188
+ const res = await getMetrics();
189
+ return {
190
+ contents: [
191
+ {
192
+ uri: "caddy://metrics",
193
+ mimeType: "text/plain",
194
+ text: res.ok ? String(res.data ?? "") : `Error: ${res.error}`
195
+ }
196
+ ]
197
+ };
198
+ }
199
+ );
200
+ server.resource(
201
+ "caddy-servers",
202
+ "caddy://servers",
203
+ { description: "Summary of all configured HTTP servers" },
204
+ async () => {
205
+ const res = await configGet("apps/http/servers");
206
+ return {
207
+ contents: [
208
+ {
209
+ uri: "caddy://servers",
210
+ mimeType: "application/json",
211
+ text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
212
+ }
213
+ ]
214
+ };
215
+ }
216
+ );
169
217
  }
170
218
 
171
219
  // src/tools/adapt.ts
@@ -191,7 +239,7 @@ function registerAdaptTools(server) {
191
239
  "Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces. Returns the adapted JSON and any warnings separately.",
192
240
  {
193
241
  config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
194
- adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
242
+ adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
195
243
  },
196
244
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
197
245
  async ({ config, adapter }) => {
@@ -228,12 +276,12 @@ function registerConfigTools(server) {
228
276
  );
229
277
  server.tool(
230
278
  "caddy_config_set",
231
- "Write config at a JSON path. Mode 'append' (default) adds to arrays or creates keys (POST). Mode 'overwrite' replaces existing values (PATCH). Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
279
+ "Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
232
280
  {
233
281
  path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
234
282
  value: z2.any().describe("The JSON value to set at the path"),
235
- mode: z2.enum(["append", "overwrite", "insert"]).optional().default("append").describe(
236
- "'append' = POST (add to arrays, create on objects), 'overwrite' = PATCH (replace existing), 'insert' = PUT (insert at array index)"
283
+ mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
284
+ "'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
237
285
  )
238
286
  },
239
287
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -266,13 +314,16 @@ function registerConfigTools(server) {
266
314
  "caddy_config_by_id",
267
315
  "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.",
268
316
  {
269
- id: z2.string().regex(/^[\w-]+$/).describe("The @id value of the config object"),
317
+ id: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
270
318
  action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
271
319
  value: z2.any().optional().describe("New value (required for 'set' action)"),
272
- subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object")
320
+ subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
321
+ mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
322
+ "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
323
+ )
273
324
  },
274
325
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
275
- async ({ id, action, value, subpath }) => {
326
+ async ({ id, action, value, subpath, mode }) => {
276
327
  if (action === "get") {
277
328
  return formatResult(await configByIdGet(id, subpath));
278
329
  }
@@ -283,7 +334,8 @@ function registerConfigTools(server) {
283
334
  content: [{ type: "text", text: "Error: value is required for 'set' action" }]
284
335
  };
285
336
  }
286
- return formatResult(await configByIdSet(id, value, "PATCH", subpath));
337
+ const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
338
+ return formatResult(await configByIdSet(id, value, method, subpath));
287
339
  }
288
340
  if (action === "delete") {
289
341
  return formatResult(await configByIdDelete(id, subpath));
@@ -373,7 +425,7 @@ ${lines.join("\n")}` }]
373
425
  "caddy_pki",
374
426
  "Get PKI certificate authority info or the CA certificate chain.",
375
427
  {
376
- ca: z3.string().regex(/^[\w-]+$/).optional().default("local").describe("CA ID (default: 'local')"),
428
+ ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
377
429
  certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
378
430
  },
379
431
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -408,6 +460,10 @@ ${lines.join("\n")}` }]
408
460
 
409
461
  // src/tools/routes.ts
410
462
  import { z as z4 } from "zod";
463
+ function safeJoin(value) {
464
+ if (!Array.isArray(value)) return "";
465
+ return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
466
+ }
411
467
  function parseFrom(from) {
412
468
  const cleaned = from.replace(/^https?:\/\//, "");
413
469
  const match = {};
@@ -443,7 +499,7 @@ function registerRouteTools(server) {
443
499
  {
444
500
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
445
501
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
446
- server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
502
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
447
503
  },
448
504
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
449
505
  async ({ from, to, server: srv }) => {
@@ -475,7 +531,7 @@ function registerRouteTools(server) {
475
531
  {
476
532
  match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
477
533
  handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
478
- server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
534
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
479
535
  terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
480
536
  },
481
537
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -492,38 +548,58 @@ function registerRouteTools(server) {
492
548
  "caddy_list_routes",
493
549
  "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
494
550
  {
495
- server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
551
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
496
552
  },
497
553
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
498
554
  async ({ server: srv }) => {
499
555
  const serverRes = await configGet(`apps/http/servers/${srv}`);
500
556
  if (!serverRes.ok) return formatResult(serverRes);
501
- const serverConfig = serverRes.data;
502
- const routes = serverConfig?.routes || [];
503
- const listen = serverConfig?.listen || [];
557
+ const serverConfig = serverRes.data || {};
558
+ const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
559
+ const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
560
+ const listenStr = listen.map(String).join(", ") || "default";
504
561
  if (routes.length === 0) {
505
562
  return {
506
563
  content: [
507
564
  {
508
565
  type: "text",
509
- text: `Server ${srv} (listen: ${listen.join(", ") || "default"}) \u2014 no routes configured`
566
+ text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
510
567
  }
511
568
  ]
512
569
  };
513
570
  }
514
- const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
571
+ const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
515
572
  for (let i = 0; i < routes.length; i++) {
516
- const route = routes[i];
517
- const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
518
- const group = route.group ? ` group="${route.group}"` : "";
519
- const matchers = (route.match || []).map((m) => {
573
+ const rawRoute = routes[i];
574
+ if (!rawRoute || typeof rawRoute !== "object") {
575
+ lines.push(` Route ${i}: <invalid>`);
576
+ continue;
577
+ }
578
+ const route = rawRoute;
579
+ const idVal = typeof route["@id"] === "string" ? route["@id"] : void 0;
580
+ const groupVal = typeof route.group === "string" ? route.group : void 0;
581
+ const id = idVal ? ` @id="${idVal}"` : "";
582
+ const group = groupVal ? ` group="${groupVal}"` : "";
583
+ const matchList = Array.isArray(route.match) ? route.match : [];
584
+ const matchers = matchList.map((rawMatcher) => {
585
+ if (!rawMatcher || typeof rawMatcher !== "object") return "catch-all";
586
+ const m = rawMatcher;
520
587
  const parts = [];
521
- if (m.host) parts.push(`host=[${m.host.join(",")}]`);
522
- if (m.path) parts.push(`path=[${m.path.join(",")}]`);
523
- if (m.method) parts.push(`method=[${m.method.join(",")}]`);
524
- if (m.protocol) parts.push(`protocol=${m.protocol}`);
525
- if (m.remote_ip) parts.push(`remote_ip=[${m.remote_ip.ranges?.join(",") || "..."}]`);
526
- if (m.client_ip) parts.push(`client_ip=[${m.client_ip.ranges?.join(",") || "..."}]`);
588
+ const host = safeJoin(m.host);
589
+ if (host) parts.push(`host=[${host}]`);
590
+ const path = safeJoin(m.path);
591
+ if (path) parts.push(`path=[${path}]`);
592
+ const method = safeJoin(m.method);
593
+ if (method) parts.push(`method=[${method}]`);
594
+ if (typeof m.protocol === "string") parts.push(`protocol=${m.protocol}`);
595
+ if (m.remote_ip && typeof m.remote_ip === "object") {
596
+ const ranges = safeJoin(m.remote_ip.ranges);
597
+ parts.push(`remote_ip=[${ranges || "..."}]`);
598
+ }
599
+ if (m.client_ip && typeof m.client_ip === "object") {
600
+ const ranges = safeJoin(m.client_ip.ranges);
601
+ parts.push(`client_ip=[${ranges || "..."}]`);
602
+ }
527
603
  if (m.query) parts.push("query=...");
528
604
  if (m.header) parts.push("header=...");
529
605
  if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
@@ -546,23 +622,51 @@ function registerRouteTools(server) {
546
622
  if (parts.length === 0) return "catch-all";
547
623
  return parts.join(" ");
548
624
  }).join(" | ");
549
- const handlers = (route.handle || []).map((h) => {
625
+ const handleList = Array.isArray(route.handle) ? route.handle : [];
626
+ const handlers = handleList.map((rawHandler) => {
627
+ if (!rawHandler || typeof rawHandler !== "object") return "unknown";
628
+ const h = rawHandler;
550
629
  if (h.handler === "reverse_proxy") {
551
- const upstreams = (h.upstreams || []).map((u) => u.dial).join(",");
630
+ const upstreamsArr = Array.isArray(h.upstreams) ? h.upstreams : [];
631
+ const upstreams = upstreamsArr.map((u) => {
632
+ if (u && typeof u === "object") {
633
+ const dial = u.dial;
634
+ if (typeof dial === "string") return dial;
635
+ }
636
+ return "?";
637
+ }).join(",");
552
638
  return `reverse_proxy(${upstreams})`;
553
639
  }
554
- if (h.handler === "file_server") return `file_server(${h.root || "."})`;
555
- if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
556
- if (h.handler === "rewrite") return `rewrite(${h.uri || "..."})`;
557
- if (h.handler === "subroute") return `subroute(${h.routes?.length || 0} routes)`;
640
+ if (h.handler === "file_server") {
641
+ const root = typeof h.root === "string" ? h.root : ".";
642
+ return `file_server(${root})`;
643
+ }
644
+ if (h.handler === "static_response") {
645
+ const status = typeof h.status_code === "number" ? h.status_code : 200;
646
+ return `static_response(${status})`;
647
+ }
648
+ if (h.handler === "rewrite") {
649
+ const uri = typeof h.uri === "string" ? h.uri : "...";
650
+ return `rewrite(${uri})`;
651
+ }
652
+ if (h.handler === "subroute") {
653
+ const count = Array.isArray(h.routes) ? h.routes.length : 0;
654
+ return `subroute(${count} routes)`;
655
+ }
558
656
  if (h.handler === "encode") return "encode";
559
657
  if (h.handler === "headers") return "headers";
560
- if (h.handler === "authentication")
561
- return `auth(${h.providers ? Object.keys(h.providers).join(",") : "..."})`;
562
- if (h.handler === "error") return `error(${h.status_code || "..."})`;
563
- return h.handler || "unknown";
658
+ if (h.handler === "authentication") {
659
+ const providers = h.providers && typeof h.providers === "object" ? Object.keys(h.providers).join(",") : "...";
660
+ return `auth(${providers})`;
661
+ }
662
+ if (h.handler === "error") {
663
+ const status = typeof h.status_code === "number" ? h.status_code : "...";
664
+ return `error(${status})`;
665
+ }
666
+ return typeof h.handler === "string" ? h.handler : "unknown";
564
667
  }).join(" \u2192 ");
565
- lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
668
+ const terminal = route.terminal === true ? " [terminal]" : "";
669
+ lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
566
670
  }
567
671
  return {
568
672
  content: [
@@ -572,6 +676,67 @@ function registerRouteTools(server) {
572
676
  };
573
677
  }
574
678
  );
679
+ server.tool(
680
+ "caddy_remove_route",
681
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
682
+ {
683
+ id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
684
+ index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
685
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
686
+ confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
687
+ },
688
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
689
+ async ({ id, index, server: srv, confirm }) => {
690
+ if (!id && index === void 0) {
691
+ return {
692
+ isError: true,
693
+ content: [{ type: "text", text: "Error: must provide either id or index" }]
694
+ };
695
+ }
696
+ if (!confirm) {
697
+ const target = id ? `@id="${id}"` : `route ${index} on server "${srv}"`;
698
+ return {
699
+ isError: true,
700
+ content: [
701
+ {
702
+ type: "text",
703
+ text: `Refusing to remove ${target} without confirm=true. Re-run with confirm:true to proceed.`
704
+ }
705
+ ]
706
+ };
707
+ }
708
+ if (id) {
709
+ const res2 = await configByIdDelete(id);
710
+ if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
711
+ return formatResult(res2);
712
+ }
713
+ const readRes = await configGet(`apps/http/servers/${srv}/routes`);
714
+ if (!readRes.ok) return formatResult(readRes);
715
+ const routes = readRes.data;
716
+ if (!Array.isArray(routes)) {
717
+ return {
718
+ isError: true,
719
+ content: [{ type: "text", text: `Error: server "${srv}" has no routes array (or it is malformed)` }]
720
+ };
721
+ }
722
+ if (index >= routes.length) {
723
+ return {
724
+ isError: true,
725
+ content: [
726
+ {
727
+ type: "text",
728
+ text: `Error: index ${index} out of range \u2014 server "${srv}" has ${routes.length} route(s)`
729
+ }
730
+ ]
731
+ };
732
+ }
733
+ const res = await configDelete(`apps/http/servers/${srv}/routes/${index}`);
734
+ if (res.ok) {
735
+ return { content: [{ type: "text", text: `Route ${index} removed from server "${srv}".` }] };
736
+ }
737
+ return formatResult(res);
738
+ }
739
+ );
575
740
  }
576
741
 
577
742
  // src/tools/tls.ts
@@ -586,6 +751,21 @@ function buildTlsConfig(fields) {
586
751
  }
587
752
  };
588
753
  }
754
+ function bothErrors(label, patchRes, postRes) {
755
+ const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
756
+ const postErr = postRes.error || `HTTP ${postRes.status}`;
757
+ return {
758
+ isError: true,
759
+ content: [
760
+ {
761
+ type: "text",
762
+ text: `Error: Failed to set ${label}.
763
+ PATCH attempt: ${patchErr}
764
+ POST fallback: ${postErr}`
765
+ }
766
+ ]
767
+ };
768
+ }
589
769
  function registerTlsTools(server) {
590
770
  server.tool(
591
771
  "caddy_tls",
@@ -606,11 +786,11 @@ function registerTlsTools(server) {
606
786
  isError: true,
607
787
  content: [{ type: "text", text: "Error: email is required for set_email action" }]
608
788
  };
609
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
610
- if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
611
- const fallback = await configPost("apps/tls", buildTlsConfig({ email }));
612
- if (fallback.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
613
- return formatResult(fallback);
789
+ const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
790
+ if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
791
+ const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
792
+ if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
793
+ return bothErrors("ACME email", patchRes, postRes);
614
794
  }
615
795
  if (action === "set_acme_ca") {
616
796
  if (!ca)
@@ -618,11 +798,11 @@ function registerTlsTools(server) {
618
798
  isError: true,
619
799
  content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
620
800
  };
621
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
622
- if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
623
- const fallback = await configPost("apps/tls", buildTlsConfig({ ca }));
624
- if (fallback.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
625
- return formatResult(fallback);
801
+ const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
802
+ if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
803
+ const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
804
+ if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
805
+ return bothErrors("ACME CA", patchRes, postRes);
626
806
  }
627
807
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
628
808
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for managing Caddy web servers via the admin API",
5
5
  "license": "MIT",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
@@ -31,6 +31,7 @@
31
31
  "typecheck": "tsc --noEmit",
32
32
  "test:ci": "npm run build && npm test",
33
33
  "prepublishOnly": "npm run build",
34
+ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
34
35
  "start": "node dist/index.js"
35
36
  },
36
37
  "dependencies": {