@yawlabs/caddy-mcp 0.2.0 → 0.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.
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
- - Node.js 18+
113
- - Caddy server with admin API enabled (default: `localhost:2019`)
203
+ - Node.js 20+
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,7 +316,7 @@ 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
322
  subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
@@ -379,7 +427,7 @@ ${lines.join("\n")}` }]
379
427
  "caddy_pki",
380
428
  "Get PKI certificate authority info or the CA certificate chain.",
381
429
  {
382
- 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')"),
383
431
  certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
384
432
  },
385
433
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -414,6 +462,10 @@ ${lines.join("\n")}` }]
414
462
 
415
463
  // src/tools/routes.ts
416
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
+ }
417
469
  function parseFrom(from) {
418
470
  const cleaned = from.replace(/^https?:\/\//, "");
419
471
  const match = {};
@@ -449,7 +501,7 @@ function registerRouteTools(server) {
449
501
  {
450
502
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
451
503
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
452
- 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)")
453
505
  },
454
506
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
455
507
  async ({ from, to, server: srv }) => {
@@ -481,7 +533,7 @@ function registerRouteTools(server) {
481
533
  {
482
534
  match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
483
535
  handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
484
- 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)"),
485
537
  terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
486
538
  },
487
539
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -498,38 +550,58 @@ function registerRouteTools(server) {
498
550
  "caddy_list_routes",
499
551
  "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
500
552
  {
501
- 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)")
502
554
  },
503
555
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
504
556
  async ({ server: srv }) => {
505
557
  const serverRes = await configGet(`apps/http/servers/${srv}`);
506
558
  if (!serverRes.ok) return formatResult(serverRes);
507
- const serverConfig = serverRes.data;
508
- const routes = serverConfig?.routes || [];
509
- 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";
510
563
  if (routes.length === 0) {
511
564
  return {
512
565
  content: [
513
566
  {
514
567
  type: "text",
515
- text: `Server ${srv} (listen: ${listen.join(", ") || "default"}) \u2014 no routes configured`
568
+ text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
516
569
  }
517
570
  ]
518
571
  };
519
572
  }
520
- const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
573
+ const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
521
574
  for (let i = 0; i < routes.length; i++) {
522
- const route = routes[i];
523
- const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
524
- const group = route.group ? ` group="${route.group}"` : "";
525
- 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;
526
589
  const parts = [];
527
- if (m.host) parts.push(`host=[${m.host.join(",")}]`);
528
- if (m.path) parts.push(`path=[${m.path.join(",")}]`);
529
- if (m.method) parts.push(`method=[${m.method.join(",")}]`);
530
- if (m.protocol) parts.push(`protocol=${m.protocol}`);
531
- if (m.remote_ip) parts.push(`remote_ip=[${m.remote_ip.ranges?.join(",") || "..."}]`);
532
- 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
+ }
533
605
  if (m.query) parts.push("query=...");
534
606
  if (m.header) parts.push("header=...");
535
607
  if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
@@ -552,23 +624,51 @@ function registerRouteTools(server) {
552
624
  if (parts.length === 0) return "catch-all";
553
625
  return parts.join(" ");
554
626
  }).join(" | ");
555
- 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;
556
631
  if (h.handler === "reverse_proxy") {
557
- 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(",");
558
640
  return `reverse_proxy(${upstreams})`;
559
641
  }
560
- if (h.handler === "file_server") return `file_server(${h.root || "."})`;
561
- if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
562
- if (h.handler === "rewrite") return `rewrite(${h.uri || "..."})`;
563
- 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
+ }
564
658
  if (h.handler === "encode") return "encode";
565
659
  if (h.handler === "headers") return "headers";
566
- if (h.handler === "authentication")
567
- return `auth(${h.providers ? Object.keys(h.providers).join(",") : "..."})`;
568
- if (h.handler === "error") return `error(${h.status_code || "..."})`;
569
- 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";
570
669
  }).join(" \u2192 ");
571
- 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}`);
572
672
  }
573
673
  return {
574
674
  content: [
@@ -578,6 +678,67 @@ function registerRouteTools(server) {
578
678
  };
579
679
  }
580
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
+ );
581
742
  }
582
743
 
583
744
  // src/tools/tls.ts
@@ -592,6 +753,21 @@ function buildTlsConfig(fields) {
592
753
  }
593
754
  };
594
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
+ }
595
771
  function registerTlsTools(server) {
596
772
  server.tool(
597
773
  "caddy_tls",
@@ -612,11 +788,11 @@ function registerTlsTools(server) {
612
788
  isError: true,
613
789
  content: [{ type: "text", text: "Error: email is required for set_email action" }]
614
790
  };
615
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
616
- if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
617
- const fallback = await configPost("apps/tls", buildTlsConfig({ email }));
618
- if (fallback.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
619
- 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);
620
796
  }
621
797
  if (action === "set_acme_ca") {
622
798
  if (!ca)
@@ -624,11 +800,11 @@ function registerTlsTools(server) {
624
800
  isError: true,
625
801
  content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
626
802
  };
627
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
628
- if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
629
- const fallback = await configPost("apps/tls", buildTlsConfig({ ca }));
630
- if (fallback.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
631
- 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);
632
808
  }
633
809
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
634
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,7 +314,7 @@ 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
320
  subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
@@ -377,7 +425,7 @@ ${lines.join("\n")}` }]
377
425
  "caddy_pki",
378
426
  "Get PKI certificate authority info or the CA certificate chain.",
379
427
  {
380
- 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')"),
381
429
  certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
382
430
  },
383
431
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -412,6 +460,10 @@ ${lines.join("\n")}` }]
412
460
 
413
461
  // src/tools/routes.ts
414
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
+ }
415
467
  function parseFrom(from) {
416
468
  const cleaned = from.replace(/^https?:\/\//, "");
417
469
  const match = {};
@@ -447,7 +499,7 @@ function registerRouteTools(server) {
447
499
  {
448
500
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
449
501
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
450
- 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)")
451
503
  },
452
504
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
453
505
  async ({ from, to, server: srv }) => {
@@ -479,7 +531,7 @@ function registerRouteTools(server) {
479
531
  {
480
532
  match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
481
533
  handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
482
- 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)"),
483
535
  terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
484
536
  },
485
537
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -496,38 +548,58 @@ function registerRouteTools(server) {
496
548
  "caddy_list_routes",
497
549
  "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
498
550
  {
499
- 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)")
500
552
  },
501
553
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
502
554
  async ({ server: srv }) => {
503
555
  const serverRes = await configGet(`apps/http/servers/${srv}`);
504
556
  if (!serverRes.ok) return formatResult(serverRes);
505
- const serverConfig = serverRes.data;
506
- const routes = serverConfig?.routes || [];
507
- 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";
508
561
  if (routes.length === 0) {
509
562
  return {
510
563
  content: [
511
564
  {
512
565
  type: "text",
513
- text: `Server ${srv} (listen: ${listen.join(", ") || "default"}) \u2014 no routes configured`
566
+ text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
514
567
  }
515
568
  ]
516
569
  };
517
570
  }
518
- const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
571
+ const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
519
572
  for (let i = 0; i < routes.length; i++) {
520
- const route = routes[i];
521
- const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
522
- const group = route.group ? ` group="${route.group}"` : "";
523
- 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;
524
587
  const parts = [];
525
- if (m.host) parts.push(`host=[${m.host.join(",")}]`);
526
- if (m.path) parts.push(`path=[${m.path.join(",")}]`);
527
- if (m.method) parts.push(`method=[${m.method.join(",")}]`);
528
- if (m.protocol) parts.push(`protocol=${m.protocol}`);
529
- if (m.remote_ip) parts.push(`remote_ip=[${m.remote_ip.ranges?.join(",") || "..."}]`);
530
- 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
+ }
531
603
  if (m.query) parts.push("query=...");
532
604
  if (m.header) parts.push("header=...");
533
605
  if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
@@ -550,23 +622,51 @@ function registerRouteTools(server) {
550
622
  if (parts.length === 0) return "catch-all";
551
623
  return parts.join(" ");
552
624
  }).join(" | ");
553
- 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;
554
629
  if (h.handler === "reverse_proxy") {
555
- 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(",");
556
638
  return `reverse_proxy(${upstreams})`;
557
639
  }
558
- if (h.handler === "file_server") return `file_server(${h.root || "."})`;
559
- if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
560
- if (h.handler === "rewrite") return `rewrite(${h.uri || "..."})`;
561
- 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
+ }
562
656
  if (h.handler === "encode") return "encode";
563
657
  if (h.handler === "headers") return "headers";
564
- if (h.handler === "authentication")
565
- return `auth(${h.providers ? Object.keys(h.providers).join(",") : "..."})`;
566
- if (h.handler === "error") return `error(${h.status_code || "..."})`;
567
- 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";
568
667
  }).join(" \u2192 ");
569
- 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}`);
570
670
  }
571
671
  return {
572
672
  content: [
@@ -576,6 +676,67 @@ function registerRouteTools(server) {
576
676
  };
577
677
  }
578
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
+ );
579
740
  }
580
741
 
581
742
  // src/tools/tls.ts
@@ -590,6 +751,21 @@ function buildTlsConfig(fields) {
590
751
  }
591
752
  };
592
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
+ }
593
769
  function registerTlsTools(server) {
594
770
  server.tool(
595
771
  "caddy_tls",
@@ -610,11 +786,11 @@ function registerTlsTools(server) {
610
786
  isError: true,
611
787
  content: [{ type: "text", text: "Error: email is required for set_email action" }]
612
788
  };
613
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
614
- if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
615
- const fallback = await configPost("apps/tls", buildTlsConfig({ email }));
616
- if (fallback.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
617
- 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);
618
794
  }
619
795
  if (action === "set_acme_ca") {
620
796
  if (!ca)
@@ -622,11 +798,11 @@ function registerTlsTools(server) {
622
798
  isError: true,
623
799
  content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
624
800
  };
625
- const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
626
- if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
627
- const fallback = await configPost("apps/tls", buildTlsConfig({ ca }));
628
- if (fallback.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
629
- 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);
630
806
  }
631
807
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
632
808
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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)",
@@ -38,6 +38,10 @@
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
39
  "zod": "^4.3.6"
40
40
  },
41
+ "overrides": {
42
+ "hono": "^4.12.14",
43
+ "@hono/node-server": "^1.19.13"
44
+ },
41
45
  "devDependencies": {
42
46
  "@biomejs/biome": "^2.4.11",
43
47
  "@types/node": "^25.6.0",
@@ -46,7 +50,7 @@
46
50
  "vitest": "^4.1.4"
47
51
  },
48
52
  "engines": {
49
- "node": ">=18"
53
+ "node": ">=20"
50
54
  },
51
55
  "keywords": [
52
56
  "mcp",