@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 +165 -59
- package/dist/index.js +223 -47
- package/dist/server.js +223 -47
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -2,115 +2,221 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/YawLabs/caddy-mcp/stargazers)
|
|
6
|
+
[](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
|
|
5
7
|
|
|
6
|
-
**
|
|
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
|
-
|
|
12
|
+
[](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
|
-
|
|
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
|
-
|
|
16
|
+
## Why this one?
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
## Quick start
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
+
**2. Create `.mcp.json` in your project root**
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
macOS / Linux / WSL:
|
|
34
39
|
|
|
35
40
|
```json
|
|
36
41
|
{
|
|
37
42
|
"mcpServers": {
|
|
38
|
-
"caddy
|
|
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
|
|
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** —
|
|
62
|
-
- **caddy_config_delete** — Delete config at a path
|
|
63
|
-
- **caddy_config_by_id** —
|
|
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
|
-
- **
|
|
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
|
|
75
|
-
- **caddy_adapt** — Convert Caddyfile to JSON
|
|
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
|
|
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
|
-
-
|
|
89
|
-
|
|
130
|
+
Browsable read-only data — MCP 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
|
-
|
|
98
|
-
→ caddy_list_routes()
|
|
163
|
+
### Safely update a route by @id
|
|
99
164
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
- Caddy
|
|
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
|
-
|
|
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 ${
|
|
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 '
|
|
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("
|
|
238
|
-
"'
|
|
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-]
|
|
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-]
|
|
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-]
|
|
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-]
|
|
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-]
|
|
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
|
|
509
|
-
const listen = serverConfig
|
|
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: ${
|
|
568
|
+
text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
|
|
516
569
|
}
|
|
517
570
|
]
|
|
518
571
|
};
|
|
519
572
|
}
|
|
520
|
-
const lines = [`Server: ${srv} (listen: ${
|
|
573
|
+
const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
|
|
521
574
|
for (let i = 0; i < routes.length; i++) {
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
if (
|
|
531
|
-
|
|
532
|
-
if (
|
|
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
|
|
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
|
|
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")
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
|
616
|
-
if (
|
|
617
|
-
const
|
|
618
|
-
if (
|
|
619
|
-
return
|
|
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
|
|
628
|
-
if (
|
|
629
|
-
const
|
|
630
|
-
if (
|
|
631
|
-
return
|
|
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
|
-
|
|
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 ${
|
|
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 '
|
|
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("
|
|
236
|
-
"'
|
|
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-]
|
|
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-]
|
|
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-]
|
|
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-]
|
|
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-]
|
|
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
|
|
507
|
-
const listen = serverConfig
|
|
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: ${
|
|
566
|
+
text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
|
|
514
567
|
}
|
|
515
568
|
]
|
|
516
569
|
};
|
|
517
570
|
}
|
|
518
|
-
const lines = [`Server: ${srv} (listen: ${
|
|
571
|
+
const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
|
|
519
572
|
for (let i = 0; i < routes.length; i++) {
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
if (
|
|
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
|
|
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
|
|
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")
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
|
614
|
-
if (
|
|
615
|
-
const
|
|
616
|
-
if (
|
|
617
|
-
return
|
|
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
|
|
626
|
-
if (
|
|
627
|
-
const
|
|
628
|
-
if (
|
|
629
|
-
return
|
|
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.
|
|
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": ">=
|
|
53
|
+
"node": ">=20"
|
|
50
54
|
},
|
|
51
55
|
"keywords": [
|
|
52
56
|
"mcp",
|