@yawlabs/caddy-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/index.js +251 -48
- package/dist/server.js +251 -48
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
**MCP server for managing Caddy web servers.**
|
|
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.
|
|
7
7
|
|
|
8
8
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
9
|
|
|
@@ -60,6 +60,7 @@ Add to your MCP config file:
|
|
|
60
60
|
- **caddy_config_get** — Read config at any JSON path (or full config)
|
|
61
61
|
- **caddy_config_set** — Create or replace config at a path
|
|
62
62
|
- **caddy_config_delete** — Delete config at a path
|
|
63
|
+
- **caddy_config_by_id** — Read, update, or delete config by `@id` tag
|
|
63
64
|
- **caddy_load** — Replace entire config atomically
|
|
64
65
|
|
|
65
66
|
### Route operations
|
|
@@ -76,7 +77,9 @@ Add to your MCP config file:
|
|
|
76
77
|
### Server operations
|
|
77
78
|
|
|
78
79
|
- **caddy_status** — Connectivity check + config summary
|
|
80
|
+
- **caddy_list_servers** — List all HTTP servers with names, addresses, and TLS status
|
|
79
81
|
- **caddy_upstreams** — Reverse proxy backend health
|
|
82
|
+
- **caddy_metrics** — Prometheus metrics (request counts, durations, connections)
|
|
80
83
|
- **caddy_pki** — CA info and certificate chains
|
|
81
84
|
- **caddy_stop** — Graceful shutdown (requires confirmation)
|
|
82
85
|
|
package/dist/index.js
CHANGED
|
@@ -8,36 +8,62 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
8
8
|
// src/api.ts
|
|
9
9
|
var DEFAULT_URL = "http://localhost:2019";
|
|
10
10
|
var TIMEOUT = 1e4;
|
|
11
|
+
var etagCache = /* @__PURE__ */ new Map();
|
|
11
12
|
function getBaseUrl() {
|
|
12
13
|
return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
|
|
13
14
|
}
|
|
14
|
-
function getHeaders(contentType
|
|
15
|
-
const headers = {
|
|
15
|
+
function getHeaders(contentType) {
|
|
16
|
+
const headers = {};
|
|
17
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
16
18
|
const token = process.env.CADDY_API_TOKEN;
|
|
17
19
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
18
20
|
return headers;
|
|
19
21
|
}
|
|
20
22
|
function normalizePath(path) {
|
|
21
|
-
return path.replace(/^\/?(config
|
|
23
|
+
return path.replace(/^\/?(config(\/|$))?/, "");
|
|
22
24
|
}
|
|
23
|
-
async function caddyRequest(method, path, body, contentType) {
|
|
25
|
+
async function caddyRequest(method, path, body, contentType, timeout) {
|
|
24
26
|
const url = `${getBaseUrl()}${path}`;
|
|
27
|
+
const effectiveTimeout = timeout ?? TIMEOUT;
|
|
25
28
|
try {
|
|
29
|
+
const hasBody = body !== void 0;
|
|
30
|
+
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
|
|
31
|
+
const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
|
|
32
|
+
const isWrite = method !== "GET";
|
|
33
|
+
if (isWrite && isConfigPath) {
|
|
34
|
+
const cachedEtag = etagCache.get(path);
|
|
35
|
+
if (cachedEtag) headers["If-Match"] = cachedEtag;
|
|
36
|
+
}
|
|
26
37
|
const res = await fetch(url, {
|
|
27
38
|
method,
|
|
28
|
-
headers
|
|
29
|
-
body:
|
|
30
|
-
signal: AbortSignal.timeout(
|
|
39
|
+
headers,
|
|
40
|
+
body: hasBody ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
|
|
41
|
+
signal: AbortSignal.timeout(effectiveTimeout)
|
|
31
42
|
});
|
|
32
43
|
const text = await res.text();
|
|
44
|
+
const etag = res.headers.get("ETag") || void 0;
|
|
45
|
+
if (method === "GET" && etag && isConfigPath) {
|
|
46
|
+
etagCache.set(path, etag);
|
|
47
|
+
}
|
|
48
|
+
if (isWrite && res.ok && isConfigPath) {
|
|
49
|
+
etagCache.delete(path);
|
|
50
|
+
}
|
|
33
51
|
if (!res.ok) {
|
|
52
|
+
if (res.status === 412) {
|
|
53
|
+
etagCache.delete(path);
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
status: 412,
|
|
57
|
+
error: "Config has been modified since it was last read (HTTP 412 Precondition Failed). Re-read the config and retry your change."
|
|
58
|
+
};
|
|
59
|
+
}
|
|
34
60
|
return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
|
|
35
61
|
}
|
|
36
|
-
if (!text) return { ok: true, status: res.status };
|
|
62
|
+
if (!text) return { ok: true, status: res.status, etag };
|
|
37
63
|
try {
|
|
38
|
-
return { ok: true, status: res.status, data: JSON.parse(text) };
|
|
64
|
+
return { ok: true, status: res.status, data: JSON.parse(text), etag };
|
|
39
65
|
} catch {
|
|
40
|
-
return { ok: true, status: res.status, data: text };
|
|
66
|
+
return { ok: true, status: res.status, data: text, etag };
|
|
41
67
|
}
|
|
42
68
|
} catch (err) {
|
|
43
69
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -49,7 +75,7 @@ async function caddyRequest(method, path, body, contentType) {
|
|
|
49
75
|
};
|
|
50
76
|
}
|
|
51
77
|
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
52
|
-
return { ok: false, status: 0, error: `Request timed out after ${
|
|
78
|
+
return { ok: false, status: 0, error: `Request timed out after ${effectiveTimeout}ms` };
|
|
53
79
|
}
|
|
54
80
|
return { ok: false, status: 0, error: msg };
|
|
55
81
|
}
|
|
@@ -62,6 +88,10 @@ function configPost(path, value) {
|
|
|
62
88
|
const normalized = normalizePath(path);
|
|
63
89
|
return caddyRequest("POST", `/config/${normalized}`, value);
|
|
64
90
|
}
|
|
91
|
+
function configPut(path, value) {
|
|
92
|
+
const normalized = normalizePath(path);
|
|
93
|
+
return caddyRequest("PUT", `/config/${normalized}`, value);
|
|
94
|
+
}
|
|
65
95
|
function configPatch(path, value) {
|
|
66
96
|
const normalized = normalizePath(path);
|
|
67
97
|
return caddyRequest("PATCH", `/config/${normalized}`, value);
|
|
@@ -70,8 +100,11 @@ function configDelete(path) {
|
|
|
70
100
|
const normalized = normalizePath(path);
|
|
71
101
|
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
72
102
|
}
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
var LOAD_TIMEOUT = 6e4;
|
|
104
|
+
async function loadConfig(config, contentType) {
|
|
105
|
+
const res = await caddyRequest("POST", "/load", config, contentType, LOAD_TIMEOUT);
|
|
106
|
+
if (res.ok) etagCache.clear();
|
|
107
|
+
return res;
|
|
75
108
|
}
|
|
76
109
|
function adapt(config, adapter = "caddyfile") {
|
|
77
110
|
return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
|
|
@@ -88,6 +121,21 @@ function getPki(ca = "local") {
|
|
|
88
121
|
function getPkiCertificates(ca = "local") {
|
|
89
122
|
return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
|
|
90
123
|
}
|
|
124
|
+
function configByIdGet(id, subpath = "") {
|
|
125
|
+
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
126
|
+
return caddyRequest("GET", path);
|
|
127
|
+
}
|
|
128
|
+
function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
129
|
+
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
130
|
+
return caddyRequest(method, path, value);
|
|
131
|
+
}
|
|
132
|
+
function configByIdDelete(id, subpath = "") {
|
|
133
|
+
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
134
|
+
return caddyRequest("DELETE", path);
|
|
135
|
+
}
|
|
136
|
+
function getMetrics() {
|
|
137
|
+
return caddyRequest("GET", "/metrics");
|
|
138
|
+
}
|
|
91
139
|
|
|
92
140
|
// src/resources.ts
|
|
93
141
|
function registerResources(server) {
|
|
@@ -133,7 +181,8 @@ function formatResult(res) {
|
|
|
133
181
|
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
134
182
|
};
|
|
135
183
|
}
|
|
136
|
-
const
|
|
184
|
+
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
185
|
+
const text = raw || "OK";
|
|
137
186
|
return { content: [{ type: "text", text }] };
|
|
138
187
|
}
|
|
139
188
|
|
|
@@ -141,13 +190,31 @@ function formatResult(res) {
|
|
|
141
190
|
function registerAdaptTools(server) {
|
|
142
191
|
server.tool(
|
|
143
192
|
"caddy_adapt",
|
|
144
|
-
"Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces.",
|
|
193
|
+
"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.",
|
|
145
194
|
{
|
|
146
195
|
config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
|
|
147
196
|
adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
|
|
148
197
|
},
|
|
149
198
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
150
|
-
async ({ config, adapter }) =>
|
|
199
|
+
async ({ config, adapter }) => {
|
|
200
|
+
const res = await adapt(config, adapter);
|
|
201
|
+
if (!res.ok) return formatResult(res);
|
|
202
|
+
const warnings = res.data?.warnings || [];
|
|
203
|
+
const result = res.data?.result;
|
|
204
|
+
const content = [];
|
|
205
|
+
if (warnings.length > 0) {
|
|
206
|
+
const warnLines = warnings.map(
|
|
207
|
+
(w) => ` - ${w.directive || "unknown"}: ${w.message || JSON.stringify(w)}`
|
|
208
|
+
);
|
|
209
|
+
content.push({ type: "text", text: `Warnings:
|
|
210
|
+
${warnLines.join("\n")}` });
|
|
211
|
+
}
|
|
212
|
+
content.push({
|
|
213
|
+
type: "text",
|
|
214
|
+
text: result !== void 0 ? JSON.stringify(result, null, 2) : "OK (no output)"
|
|
215
|
+
});
|
|
216
|
+
return { content };
|
|
217
|
+
}
|
|
151
218
|
);
|
|
152
219
|
}
|
|
153
220
|
|
|
@@ -163,15 +230,17 @@ function registerConfigTools(server) {
|
|
|
163
230
|
);
|
|
164
231
|
server.tool(
|
|
165
232
|
"caddy_config_set",
|
|
166
|
-
"
|
|
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.",
|
|
167
234
|
{
|
|
168
235
|
path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
169
236
|
value: z2.any().describe("The JSON value to set at the path"),
|
|
170
|
-
mode: z2.enum(["
|
|
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)"
|
|
239
|
+
)
|
|
171
240
|
},
|
|
172
241
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
173
242
|
async ({ path, value, mode }) => {
|
|
174
|
-
const res = mode === "
|
|
243
|
+
const res = mode === "overwrite" ? await configPatch(path, value) : mode === "insert" ? await configPut(path, value) : await configPost(path, value);
|
|
175
244
|
return formatResult(res);
|
|
176
245
|
}
|
|
177
246
|
);
|
|
@@ -184,10 +253,49 @@ function registerConfigTools(server) {
|
|
|
184
253
|
);
|
|
185
254
|
server.tool(
|
|
186
255
|
"caddy_load",
|
|
187
|
-
"Replace the entire Caddy configuration atomically. Accepts a
|
|
188
|
-
{
|
|
256
|
+
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning.",
|
|
257
|
+
{
|
|
258
|
+
config: z2.union([z2.record(z2.string(), z2.any()), z2.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
259
|
+
format: z2.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
|
|
260
|
+
},
|
|
189
261
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
190
|
-
async ({ config }) =>
|
|
262
|
+
async ({ config, format }) => {
|
|
263
|
+
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
264
|
+
return formatResult(await loadConfig(config, contentType));
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
server.tool(
|
|
268
|
+
"caddy_config_by_id",
|
|
269
|
+
"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
|
+
{
|
|
271
|
+
id: z2.string().regex(/^[\w-]+$/).describe("The @id value of the config object"),
|
|
272
|
+
action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
273
|
+
value: z2.any().optional().describe("New value (required for 'set' action)"),
|
|
274
|
+
subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
275
|
+
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
276
|
+
"For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
|
|
277
|
+
)
|
|
278
|
+
},
|
|
279
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
280
|
+
async ({ id, action, value, subpath, mode }) => {
|
|
281
|
+
if (action === "get") {
|
|
282
|
+
return formatResult(await configByIdGet(id, subpath));
|
|
283
|
+
}
|
|
284
|
+
if (action === "set") {
|
|
285
|
+
if (value === void 0) {
|
|
286
|
+
return {
|
|
287
|
+
isError: true,
|
|
288
|
+
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
|
|
292
|
+
return formatResult(await configByIdSet(id, value, method, subpath));
|
|
293
|
+
}
|
|
294
|
+
if (action === "delete") {
|
|
295
|
+
return formatResult(await configByIdDelete(id, subpath));
|
|
296
|
+
}
|
|
297
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
298
|
+
}
|
|
191
299
|
);
|
|
192
300
|
}
|
|
193
301
|
|
|
@@ -214,7 +322,9 @@ function registerOperationalTools(server) {
|
|
|
214
322
|
const srv = servers[name];
|
|
215
323
|
const listen = srv.listen || [];
|
|
216
324
|
const routes = srv.routes || [];
|
|
217
|
-
const
|
|
325
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
326
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
327
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
218
328
|
lines.push(
|
|
219
329
|
`Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
|
|
220
330
|
);
|
|
@@ -229,6 +339,35 @@ ACME email: ${email}`);
|
|
|
229
339
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
230
340
|
}
|
|
231
341
|
);
|
|
342
|
+
server.tool(
|
|
343
|
+
"caddy_list_servers",
|
|
344
|
+
"List all configured HTTP servers with their names, listen addresses, route counts, and TLS status. Use this to discover server names before calling route tools.",
|
|
345
|
+
{},
|
|
346
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
347
|
+
async () => {
|
|
348
|
+
const res = await configGet("apps/http/servers");
|
|
349
|
+
if (!res.ok) return formatResult(res);
|
|
350
|
+
const servers = res.data || {};
|
|
351
|
+
const names = Object.keys(servers);
|
|
352
|
+
if (names.length === 0) {
|
|
353
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
354
|
+
}
|
|
355
|
+
const lines = [];
|
|
356
|
+
for (const name of names) {
|
|
357
|
+
const srv = servers[name];
|
|
358
|
+
const listen = srv.listen || [];
|
|
359
|
+
const routes = srv.routes || [];
|
|
360
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
361
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
362
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
363
|
+
lines.push(` ${name}: ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`);
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: `HTTP Servers:
|
|
367
|
+
${lines.join("\n")}` }]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
);
|
|
232
371
|
server.tool(
|
|
233
372
|
"caddy_upstreams",
|
|
234
373
|
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
@@ -240,7 +379,7 @@ ACME email: ${email}`);
|
|
|
240
379
|
"caddy_pki",
|
|
241
380
|
"Get PKI certificate authority info or the CA certificate chain.",
|
|
242
381
|
{
|
|
243
|
-
ca: z3.string().optional().default("local").describe("CA ID (default: 'local')"),
|
|
382
|
+
ca: z3.string().regex(/^[\w-]+$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
244
383
|
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
245
384
|
},
|
|
246
385
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -249,6 +388,13 @@ ACME email: ${email}`);
|
|
|
249
388
|
return formatResult(res);
|
|
250
389
|
}
|
|
251
390
|
);
|
|
391
|
+
server.tool(
|
|
392
|
+
"caddy_metrics",
|
|
393
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
|
|
394
|
+
{},
|
|
395
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
396
|
+
async () => formatResult(await getMetrics())
|
|
397
|
+
);
|
|
252
398
|
server.tool(
|
|
253
399
|
"caddy_stop",
|
|
254
400
|
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
@@ -269,18 +415,33 @@ ACME email: ${email}`);
|
|
|
269
415
|
// src/tools/routes.ts
|
|
270
416
|
import { z as z4 } from "zod";
|
|
271
417
|
function parseFrom(from) {
|
|
418
|
+
const cleaned = from.replace(/^https?:\/\//, "");
|
|
272
419
|
const match = {};
|
|
273
|
-
const slashIdx =
|
|
420
|
+
const slashIdx = cleaned.indexOf("/");
|
|
274
421
|
if (slashIdx > 0) {
|
|
275
|
-
match.host = [
|
|
276
|
-
match.path = [
|
|
277
|
-
} else if (
|
|
278
|
-
match.path = [
|
|
422
|
+
match.host = [cleaned.substring(0, slashIdx)];
|
|
423
|
+
match.path = [cleaned.substring(slashIdx)];
|
|
424
|
+
} else if (cleaned.startsWith("/")) {
|
|
425
|
+
match.path = [cleaned];
|
|
279
426
|
} else {
|
|
280
|
-
match.host = [
|
|
427
|
+
match.host = [cleaned];
|
|
281
428
|
}
|
|
282
429
|
return match;
|
|
283
430
|
}
|
|
431
|
+
function cleanUpstreamAddr(addr) {
|
|
432
|
+
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
433
|
+
}
|
|
434
|
+
function serverNotFoundError(srv) {
|
|
435
|
+
return {
|
|
436
|
+
isError: true,
|
|
437
|
+
content: [
|
|
438
|
+
{
|
|
439
|
+
type: "text",
|
|
440
|
+
text: `Error: Server "${srv}" does not exist. Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
|
|
441
|
+
}
|
|
442
|
+
]
|
|
443
|
+
};
|
|
444
|
+
}
|
|
284
445
|
function registerRouteTools(server) {
|
|
285
446
|
server.tool(
|
|
286
447
|
"caddy_reverse_proxy",
|
|
@@ -288,24 +449,28 @@ function registerRouteTools(server) {
|
|
|
288
449
|
{
|
|
289
450
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
290
451
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
291
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
452
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
292
453
|
},
|
|
293
454
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
294
455
|
async ({ from, to, server: srv }) => {
|
|
295
456
|
const match = parseFrom(from);
|
|
457
|
+
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
296
458
|
const route = {
|
|
297
459
|
match: [match],
|
|
298
460
|
handle: [
|
|
299
461
|
{
|
|
300
462
|
handler: "reverse_proxy",
|
|
301
|
-
upstreams:
|
|
463
|
+
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
302
464
|
}
|
|
303
465
|
],
|
|
304
466
|
terminal: true
|
|
305
467
|
};
|
|
306
468
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
307
469
|
if (res.ok) {
|
|
308
|
-
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${
|
|
470
|
+
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
|
|
471
|
+
}
|
|
472
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
473
|
+
return serverNotFoundError(srv);
|
|
309
474
|
}
|
|
310
475
|
return formatResult(res);
|
|
311
476
|
}
|
|
@@ -314,15 +479,18 @@ function registerRouteTools(server) {
|
|
|
314
479
|
"caddy_add_route",
|
|
315
480
|
"Add a route with full control over match conditions and handlers. Supports any Caddy handler (reverse_proxy, file_server, static_response, redirect, encode, headers, etc.).",
|
|
316
481
|
{
|
|
317
|
-
match: z4.array(z4.record(z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
318
|
-
handle: z4.array(z4.record(z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
|
|
319
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
482
|
+
match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
483
|
+
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)"),
|
|
320
485
|
terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
|
|
321
486
|
},
|
|
322
487
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
323
488
|
async ({ match, handle, server: srv, terminal }) => {
|
|
324
489
|
const route = { match, handle, terminal };
|
|
325
490
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
491
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
492
|
+
return serverNotFoundError(srv);
|
|
493
|
+
}
|
|
326
494
|
return formatResult(res);
|
|
327
495
|
}
|
|
328
496
|
);
|
|
@@ -330,7 +498,7 @@ function registerRouteTools(server) {
|
|
|
330
498
|
"caddy_list_routes",
|
|
331
499
|
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
332
500
|
{
|
|
333
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
501
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
334
502
|
},
|
|
335
503
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
336
504
|
async ({ server: srv }) => {
|
|
@@ -352,12 +520,35 @@ function registerRouteTools(server) {
|
|
|
352
520
|
const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
|
|
353
521
|
for (let i = 0; i < routes.length; i++) {
|
|
354
522
|
const route = routes[i];
|
|
523
|
+
const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
|
|
524
|
+
const group = route.group ? ` group="${route.group}"` : "";
|
|
355
525
|
const matchers = (route.match || []).map((m) => {
|
|
356
526
|
const parts = [];
|
|
357
527
|
if (m.host) parts.push(`host=[${m.host.join(",")}]`);
|
|
358
528
|
if (m.path) parts.push(`path=[${m.path.join(",")}]`);
|
|
359
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(",") || "..."}]`);
|
|
533
|
+
if (m.query) parts.push("query=...");
|
|
360
534
|
if (m.header) parts.push("header=...");
|
|
535
|
+
if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
|
|
536
|
+
if (m.not) parts.push("not(...)");
|
|
537
|
+
const known = /* @__PURE__ */ new Set([
|
|
538
|
+
"host",
|
|
539
|
+
"path",
|
|
540
|
+
"method",
|
|
541
|
+
"protocol",
|
|
542
|
+
"remote_ip",
|
|
543
|
+
"client_ip",
|
|
544
|
+
"query",
|
|
545
|
+
"header",
|
|
546
|
+
"expression",
|
|
547
|
+
"not"
|
|
548
|
+
]);
|
|
549
|
+
for (const key of Object.keys(m)) {
|
|
550
|
+
if (!known.has(key)) parts.push(`${key}=...`);
|
|
551
|
+
}
|
|
361
552
|
if (parts.length === 0) return "catch-all";
|
|
362
553
|
return parts.join(" ");
|
|
363
554
|
}).join(" | ");
|
|
@@ -368,11 +559,16 @@ function registerRouteTools(server) {
|
|
|
368
559
|
}
|
|
369
560
|
if (h.handler === "file_server") return `file_server(${h.root || "."})`;
|
|
370
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)`;
|
|
371
564
|
if (h.handler === "encode") return "encode";
|
|
372
565
|
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 || "..."})`;
|
|
373
569
|
return h.handler || "unknown";
|
|
374
570
|
}).join(" \u2192 ");
|
|
375
|
-
lines.push(` Route ${i}
|
|
571
|
+
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
|
|
376
572
|
}
|
|
377
573
|
return {
|
|
378
574
|
content: [
|
|
@@ -386,10 +582,20 @@ function registerRouteTools(server) {
|
|
|
386
582
|
|
|
387
583
|
// src/tools/tls.ts
|
|
388
584
|
import { z as z5 } from "zod";
|
|
585
|
+
function buildTlsConfig(fields) {
|
|
586
|
+
const issuer = { module: "acme" };
|
|
587
|
+
if (fields.email) issuer.email = fields.email;
|
|
588
|
+
if (fields.ca) issuer.ca = fields.ca;
|
|
589
|
+
return {
|
|
590
|
+
automation: {
|
|
591
|
+
policies: [{ issuers: [issuer] }]
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
389
595
|
function registerTlsTools(server) {
|
|
390
596
|
server.tool(
|
|
391
597
|
"caddy_tls",
|
|
392
|
-
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL.",
|
|
598
|
+
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL. Works on both fresh and existing Caddy instances.",
|
|
393
599
|
{
|
|
394
600
|
action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
|
|
395
601
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
@@ -408,7 +614,9 @@ function registerTlsTools(server) {
|
|
|
408
614
|
};
|
|
409
615
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
410
616
|
if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
411
|
-
|
|
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);
|
|
412
620
|
}
|
|
413
621
|
if (action === "set_acme_ca") {
|
|
414
622
|
if (!ca)
|
|
@@ -418,7 +626,9 @@ function registerTlsTools(server) {
|
|
|
418
626
|
};
|
|
419
627
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
420
628
|
if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
421
|
-
|
|
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);
|
|
422
632
|
}
|
|
423
633
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
424
634
|
}
|
|
@@ -443,13 +653,6 @@ async function startServer() {
|
|
|
443
653
|
const transport = new StdioServerTransport();
|
|
444
654
|
await server.connect(transport);
|
|
445
655
|
}
|
|
446
|
-
var isDirectRun = process.argv[1]?.endsWith("server.js");
|
|
447
|
-
if (isDirectRun) {
|
|
448
|
-
startServer().catch((err) => {
|
|
449
|
-
console.error("MCP server error:", err);
|
|
450
|
-
process.exit(1);
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
656
|
|
|
454
657
|
// src/index.ts
|
|
455
658
|
startServer().catch((err) => {
|
package/dist/server.js
CHANGED
|
@@ -6,36 +6,62 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6
6
|
// src/api.ts
|
|
7
7
|
var DEFAULT_URL = "http://localhost:2019";
|
|
8
8
|
var TIMEOUT = 1e4;
|
|
9
|
+
var etagCache = /* @__PURE__ */ new Map();
|
|
9
10
|
function getBaseUrl() {
|
|
10
11
|
return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
|
|
11
12
|
}
|
|
12
|
-
function getHeaders(contentType
|
|
13
|
-
const headers = {
|
|
13
|
+
function getHeaders(contentType) {
|
|
14
|
+
const headers = {};
|
|
15
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
14
16
|
const token = process.env.CADDY_API_TOKEN;
|
|
15
17
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
16
18
|
return headers;
|
|
17
19
|
}
|
|
18
20
|
function normalizePath(path) {
|
|
19
|
-
return path.replace(/^\/?(config
|
|
21
|
+
return path.replace(/^\/?(config(\/|$))?/, "");
|
|
20
22
|
}
|
|
21
|
-
async function caddyRequest(method, path, body, contentType) {
|
|
23
|
+
async function caddyRequest(method, path, body, contentType, timeout) {
|
|
22
24
|
const url = `${getBaseUrl()}${path}`;
|
|
25
|
+
const effectiveTimeout = timeout ?? TIMEOUT;
|
|
23
26
|
try {
|
|
27
|
+
const hasBody = body !== void 0;
|
|
28
|
+
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
|
|
29
|
+
const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
|
|
30
|
+
const isWrite = method !== "GET";
|
|
31
|
+
if (isWrite && isConfigPath) {
|
|
32
|
+
const cachedEtag = etagCache.get(path);
|
|
33
|
+
if (cachedEtag) headers["If-Match"] = cachedEtag;
|
|
34
|
+
}
|
|
24
35
|
const res = await fetch(url, {
|
|
25
36
|
method,
|
|
26
|
-
headers
|
|
27
|
-
body:
|
|
28
|
-
signal: AbortSignal.timeout(
|
|
37
|
+
headers,
|
|
38
|
+
body: hasBody ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
|
|
39
|
+
signal: AbortSignal.timeout(effectiveTimeout)
|
|
29
40
|
});
|
|
30
41
|
const text = await res.text();
|
|
42
|
+
const etag = res.headers.get("ETag") || void 0;
|
|
43
|
+
if (method === "GET" && etag && isConfigPath) {
|
|
44
|
+
etagCache.set(path, etag);
|
|
45
|
+
}
|
|
46
|
+
if (isWrite && res.ok && isConfigPath) {
|
|
47
|
+
etagCache.delete(path);
|
|
48
|
+
}
|
|
31
49
|
if (!res.ok) {
|
|
50
|
+
if (res.status === 412) {
|
|
51
|
+
etagCache.delete(path);
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 412,
|
|
55
|
+
error: "Config has been modified since it was last read (HTTP 412 Precondition Failed). Re-read the config and retry your change."
|
|
56
|
+
};
|
|
57
|
+
}
|
|
32
58
|
return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
|
|
33
59
|
}
|
|
34
|
-
if (!text) return { ok: true, status: res.status };
|
|
60
|
+
if (!text) return { ok: true, status: res.status, etag };
|
|
35
61
|
try {
|
|
36
|
-
return { ok: true, status: res.status, data: JSON.parse(text) };
|
|
62
|
+
return { ok: true, status: res.status, data: JSON.parse(text), etag };
|
|
37
63
|
} catch {
|
|
38
|
-
return { ok: true, status: res.status, data: text };
|
|
64
|
+
return { ok: true, status: res.status, data: text, etag };
|
|
39
65
|
}
|
|
40
66
|
} catch (err) {
|
|
41
67
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -47,7 +73,7 @@ async function caddyRequest(method, path, body, contentType) {
|
|
|
47
73
|
};
|
|
48
74
|
}
|
|
49
75
|
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
50
|
-
return { ok: false, status: 0, error: `Request timed out after ${
|
|
76
|
+
return { ok: false, status: 0, error: `Request timed out after ${effectiveTimeout}ms` };
|
|
51
77
|
}
|
|
52
78
|
return { ok: false, status: 0, error: msg };
|
|
53
79
|
}
|
|
@@ -60,6 +86,10 @@ function configPost(path, value) {
|
|
|
60
86
|
const normalized = normalizePath(path);
|
|
61
87
|
return caddyRequest("POST", `/config/${normalized}`, value);
|
|
62
88
|
}
|
|
89
|
+
function configPut(path, value) {
|
|
90
|
+
const normalized = normalizePath(path);
|
|
91
|
+
return caddyRequest("PUT", `/config/${normalized}`, value);
|
|
92
|
+
}
|
|
63
93
|
function configPatch(path, value) {
|
|
64
94
|
const normalized = normalizePath(path);
|
|
65
95
|
return caddyRequest("PATCH", `/config/${normalized}`, value);
|
|
@@ -68,8 +98,11 @@ function configDelete(path) {
|
|
|
68
98
|
const normalized = normalizePath(path);
|
|
69
99
|
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
70
100
|
}
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
var LOAD_TIMEOUT = 6e4;
|
|
102
|
+
async function loadConfig(config, contentType) {
|
|
103
|
+
const res = await caddyRequest("POST", "/load", config, contentType, LOAD_TIMEOUT);
|
|
104
|
+
if (res.ok) etagCache.clear();
|
|
105
|
+
return res;
|
|
73
106
|
}
|
|
74
107
|
function adapt(config, adapter = "caddyfile") {
|
|
75
108
|
return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
|
|
@@ -86,6 +119,21 @@ function getPki(ca = "local") {
|
|
|
86
119
|
function getPkiCertificates(ca = "local") {
|
|
87
120
|
return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
|
|
88
121
|
}
|
|
122
|
+
function configByIdGet(id, subpath = "") {
|
|
123
|
+
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
124
|
+
return caddyRequest("GET", path);
|
|
125
|
+
}
|
|
126
|
+
function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
127
|
+
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
128
|
+
return caddyRequest(method, path, value);
|
|
129
|
+
}
|
|
130
|
+
function configByIdDelete(id, subpath = "") {
|
|
131
|
+
const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
|
|
132
|
+
return caddyRequest("DELETE", path);
|
|
133
|
+
}
|
|
134
|
+
function getMetrics() {
|
|
135
|
+
return caddyRequest("GET", "/metrics");
|
|
136
|
+
}
|
|
89
137
|
|
|
90
138
|
// src/resources.ts
|
|
91
139
|
function registerResources(server) {
|
|
@@ -131,7 +179,8 @@ function formatResult(res) {
|
|
|
131
179
|
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
132
180
|
};
|
|
133
181
|
}
|
|
134
|
-
const
|
|
182
|
+
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
183
|
+
const text = raw || "OK";
|
|
135
184
|
return { content: [{ type: "text", text }] };
|
|
136
185
|
}
|
|
137
186
|
|
|
@@ -139,13 +188,31 @@ function formatResult(res) {
|
|
|
139
188
|
function registerAdaptTools(server) {
|
|
140
189
|
server.tool(
|
|
141
190
|
"caddy_adapt",
|
|
142
|
-
"Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces.",
|
|
191
|
+
"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.",
|
|
143
192
|
{
|
|
144
193
|
config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
|
|
145
194
|
adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
|
|
146
195
|
},
|
|
147
196
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
148
|
-
async ({ config, adapter }) =>
|
|
197
|
+
async ({ config, adapter }) => {
|
|
198
|
+
const res = await adapt(config, adapter);
|
|
199
|
+
if (!res.ok) return formatResult(res);
|
|
200
|
+
const warnings = res.data?.warnings || [];
|
|
201
|
+
const result = res.data?.result;
|
|
202
|
+
const content = [];
|
|
203
|
+
if (warnings.length > 0) {
|
|
204
|
+
const warnLines = warnings.map(
|
|
205
|
+
(w) => ` - ${w.directive || "unknown"}: ${w.message || JSON.stringify(w)}`
|
|
206
|
+
);
|
|
207
|
+
content.push({ type: "text", text: `Warnings:
|
|
208
|
+
${warnLines.join("\n")}` });
|
|
209
|
+
}
|
|
210
|
+
content.push({
|
|
211
|
+
type: "text",
|
|
212
|
+
text: result !== void 0 ? JSON.stringify(result, null, 2) : "OK (no output)"
|
|
213
|
+
});
|
|
214
|
+
return { content };
|
|
215
|
+
}
|
|
149
216
|
);
|
|
150
217
|
}
|
|
151
218
|
|
|
@@ -161,15 +228,17 @@ function registerConfigTools(server) {
|
|
|
161
228
|
);
|
|
162
229
|
server.tool(
|
|
163
230
|
"caddy_config_set",
|
|
164
|
-
"
|
|
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.",
|
|
165
232
|
{
|
|
166
233
|
path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
167
234
|
value: z2.any().describe("The JSON value to set at the path"),
|
|
168
|
-
mode: z2.enum(["
|
|
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)"
|
|
237
|
+
)
|
|
169
238
|
},
|
|
170
239
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
171
240
|
async ({ path, value, mode }) => {
|
|
172
|
-
const res = mode === "
|
|
241
|
+
const res = mode === "overwrite" ? await configPatch(path, value) : mode === "insert" ? await configPut(path, value) : await configPost(path, value);
|
|
173
242
|
return formatResult(res);
|
|
174
243
|
}
|
|
175
244
|
);
|
|
@@ -182,10 +251,49 @@ function registerConfigTools(server) {
|
|
|
182
251
|
);
|
|
183
252
|
server.tool(
|
|
184
253
|
"caddy_load",
|
|
185
|
-
"Replace the entire Caddy configuration atomically. Accepts a
|
|
186
|
-
{
|
|
254
|
+
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning.",
|
|
255
|
+
{
|
|
256
|
+
config: z2.union([z2.record(z2.string(), z2.any()), z2.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
257
|
+
format: z2.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
|
|
258
|
+
},
|
|
187
259
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
188
|
-
async ({ config }) =>
|
|
260
|
+
async ({ config, format }) => {
|
|
261
|
+
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
262
|
+
return formatResult(await loadConfig(config, contentType));
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
server.tool(
|
|
266
|
+
"caddy_config_by_id",
|
|
267
|
+
"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
|
+
{
|
|
269
|
+
id: z2.string().regex(/^[\w-]+$/).describe("The @id value of the config object"),
|
|
270
|
+
action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
271
|
+
value: z2.any().optional().describe("New value (required for 'set' action)"),
|
|
272
|
+
subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
273
|
+
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
274
|
+
"For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
|
|
275
|
+
)
|
|
276
|
+
},
|
|
277
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
278
|
+
async ({ id, action, value, subpath, mode }) => {
|
|
279
|
+
if (action === "get") {
|
|
280
|
+
return formatResult(await configByIdGet(id, subpath));
|
|
281
|
+
}
|
|
282
|
+
if (action === "set") {
|
|
283
|
+
if (value === void 0) {
|
|
284
|
+
return {
|
|
285
|
+
isError: true,
|
|
286
|
+
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
|
|
290
|
+
return formatResult(await configByIdSet(id, value, method, subpath));
|
|
291
|
+
}
|
|
292
|
+
if (action === "delete") {
|
|
293
|
+
return formatResult(await configByIdDelete(id, subpath));
|
|
294
|
+
}
|
|
295
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
296
|
+
}
|
|
189
297
|
);
|
|
190
298
|
}
|
|
191
299
|
|
|
@@ -212,7 +320,9 @@ function registerOperationalTools(server) {
|
|
|
212
320
|
const srv = servers[name];
|
|
213
321
|
const listen = srv.listen || [];
|
|
214
322
|
const routes = srv.routes || [];
|
|
215
|
-
const
|
|
323
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
324
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
325
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
216
326
|
lines.push(
|
|
217
327
|
`Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
|
|
218
328
|
);
|
|
@@ -227,6 +337,35 @@ ACME email: ${email}`);
|
|
|
227
337
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
228
338
|
}
|
|
229
339
|
);
|
|
340
|
+
server.tool(
|
|
341
|
+
"caddy_list_servers",
|
|
342
|
+
"List all configured HTTP servers with their names, listen addresses, route counts, and TLS status. Use this to discover server names before calling route tools.",
|
|
343
|
+
{},
|
|
344
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
345
|
+
async () => {
|
|
346
|
+
const res = await configGet("apps/http/servers");
|
|
347
|
+
if (!res.ok) return formatResult(res);
|
|
348
|
+
const servers = res.data || {};
|
|
349
|
+
const names = Object.keys(servers);
|
|
350
|
+
if (names.length === 0) {
|
|
351
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
352
|
+
}
|
|
353
|
+
const lines = [];
|
|
354
|
+
for (const name of names) {
|
|
355
|
+
const srv = servers[name];
|
|
356
|
+
const listen = srv.listen || [];
|
|
357
|
+
const routes = srv.routes || [];
|
|
358
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
359
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
360
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
361
|
+
lines.push(` ${name}: ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`);
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: "text", text: `HTTP Servers:
|
|
365
|
+
${lines.join("\n")}` }]
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
);
|
|
230
369
|
server.tool(
|
|
231
370
|
"caddy_upstreams",
|
|
232
371
|
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
@@ -238,7 +377,7 @@ ACME email: ${email}`);
|
|
|
238
377
|
"caddy_pki",
|
|
239
378
|
"Get PKI certificate authority info or the CA certificate chain.",
|
|
240
379
|
{
|
|
241
|
-
ca: z3.string().optional().default("local").describe("CA ID (default: 'local')"),
|
|
380
|
+
ca: z3.string().regex(/^[\w-]+$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
242
381
|
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
243
382
|
},
|
|
244
383
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -247,6 +386,13 @@ ACME email: ${email}`);
|
|
|
247
386
|
return formatResult(res);
|
|
248
387
|
}
|
|
249
388
|
);
|
|
389
|
+
server.tool(
|
|
390
|
+
"caddy_metrics",
|
|
391
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
|
|
392
|
+
{},
|
|
393
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
394
|
+
async () => formatResult(await getMetrics())
|
|
395
|
+
);
|
|
250
396
|
server.tool(
|
|
251
397
|
"caddy_stop",
|
|
252
398
|
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
@@ -267,18 +413,33 @@ ACME email: ${email}`);
|
|
|
267
413
|
// src/tools/routes.ts
|
|
268
414
|
import { z as z4 } from "zod";
|
|
269
415
|
function parseFrom(from) {
|
|
416
|
+
const cleaned = from.replace(/^https?:\/\//, "");
|
|
270
417
|
const match = {};
|
|
271
|
-
const slashIdx =
|
|
418
|
+
const slashIdx = cleaned.indexOf("/");
|
|
272
419
|
if (slashIdx > 0) {
|
|
273
|
-
match.host = [
|
|
274
|
-
match.path = [
|
|
275
|
-
} else if (
|
|
276
|
-
match.path = [
|
|
420
|
+
match.host = [cleaned.substring(0, slashIdx)];
|
|
421
|
+
match.path = [cleaned.substring(slashIdx)];
|
|
422
|
+
} else if (cleaned.startsWith("/")) {
|
|
423
|
+
match.path = [cleaned];
|
|
277
424
|
} else {
|
|
278
|
-
match.host = [
|
|
425
|
+
match.host = [cleaned];
|
|
279
426
|
}
|
|
280
427
|
return match;
|
|
281
428
|
}
|
|
429
|
+
function cleanUpstreamAddr(addr) {
|
|
430
|
+
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
431
|
+
}
|
|
432
|
+
function serverNotFoundError(srv) {
|
|
433
|
+
return {
|
|
434
|
+
isError: true,
|
|
435
|
+
content: [
|
|
436
|
+
{
|
|
437
|
+
type: "text",
|
|
438
|
+
text: `Error: Server "${srv}" does not exist. Use caddy_list_servers to see available servers, or create one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
|
|
439
|
+
}
|
|
440
|
+
]
|
|
441
|
+
};
|
|
442
|
+
}
|
|
282
443
|
function registerRouteTools(server) {
|
|
283
444
|
server.tool(
|
|
284
445
|
"caddy_reverse_proxy",
|
|
@@ -286,24 +447,28 @@ function registerRouteTools(server) {
|
|
|
286
447
|
{
|
|
287
448
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
288
449
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
289
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
450
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
290
451
|
},
|
|
291
452
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
292
453
|
async ({ from, to, server: srv }) => {
|
|
293
454
|
const match = parseFrom(from);
|
|
455
|
+
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
294
456
|
const route = {
|
|
295
457
|
match: [match],
|
|
296
458
|
handle: [
|
|
297
459
|
{
|
|
298
460
|
handler: "reverse_proxy",
|
|
299
|
-
upstreams:
|
|
461
|
+
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
300
462
|
}
|
|
301
463
|
],
|
|
302
464
|
terminal: true
|
|
303
465
|
};
|
|
304
466
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
305
467
|
if (res.ok) {
|
|
306
|
-
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${
|
|
468
|
+
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
|
|
469
|
+
}
|
|
470
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
471
|
+
return serverNotFoundError(srv);
|
|
307
472
|
}
|
|
308
473
|
return formatResult(res);
|
|
309
474
|
}
|
|
@@ -312,15 +477,18 @@ function registerRouteTools(server) {
|
|
|
312
477
|
"caddy_add_route",
|
|
313
478
|
"Add a route with full control over match conditions and handlers. Supports any Caddy handler (reverse_proxy, file_server, static_response, redirect, encode, headers, etc.).",
|
|
314
479
|
{
|
|
315
|
-
match: z4.array(z4.record(z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
316
|
-
handle: z4.array(z4.record(z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
|
|
317
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
480
|
+
match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
481
|
+
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)"),
|
|
318
483
|
terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
|
|
319
484
|
},
|
|
320
485
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
321
486
|
async ({ match, handle, server: srv, terminal }) => {
|
|
322
487
|
const route = { match, handle, terminal };
|
|
323
488
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
489
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
490
|
+
return serverNotFoundError(srv);
|
|
491
|
+
}
|
|
324
492
|
return formatResult(res);
|
|
325
493
|
}
|
|
326
494
|
);
|
|
@@ -328,7 +496,7 @@ function registerRouteTools(server) {
|
|
|
328
496
|
"caddy_list_routes",
|
|
329
497
|
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
330
498
|
{
|
|
331
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
499
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
332
500
|
},
|
|
333
501
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
334
502
|
async ({ server: srv }) => {
|
|
@@ -350,12 +518,35 @@ function registerRouteTools(server) {
|
|
|
350
518
|
const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
|
|
351
519
|
for (let i = 0; i < routes.length; i++) {
|
|
352
520
|
const route = routes[i];
|
|
521
|
+
const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
|
|
522
|
+
const group = route.group ? ` group="${route.group}"` : "";
|
|
353
523
|
const matchers = (route.match || []).map((m) => {
|
|
354
524
|
const parts = [];
|
|
355
525
|
if (m.host) parts.push(`host=[${m.host.join(",")}]`);
|
|
356
526
|
if (m.path) parts.push(`path=[${m.path.join(",")}]`);
|
|
357
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(",") || "..."}]`);
|
|
531
|
+
if (m.query) parts.push("query=...");
|
|
358
532
|
if (m.header) parts.push("header=...");
|
|
533
|
+
if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
|
|
534
|
+
if (m.not) parts.push("not(...)");
|
|
535
|
+
const known = /* @__PURE__ */ new Set([
|
|
536
|
+
"host",
|
|
537
|
+
"path",
|
|
538
|
+
"method",
|
|
539
|
+
"protocol",
|
|
540
|
+
"remote_ip",
|
|
541
|
+
"client_ip",
|
|
542
|
+
"query",
|
|
543
|
+
"header",
|
|
544
|
+
"expression",
|
|
545
|
+
"not"
|
|
546
|
+
]);
|
|
547
|
+
for (const key of Object.keys(m)) {
|
|
548
|
+
if (!known.has(key)) parts.push(`${key}=...`);
|
|
549
|
+
}
|
|
359
550
|
if (parts.length === 0) return "catch-all";
|
|
360
551
|
return parts.join(" ");
|
|
361
552
|
}).join(" | ");
|
|
@@ -366,11 +557,16 @@ function registerRouteTools(server) {
|
|
|
366
557
|
}
|
|
367
558
|
if (h.handler === "file_server") return `file_server(${h.root || "."})`;
|
|
368
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)`;
|
|
369
562
|
if (h.handler === "encode") return "encode";
|
|
370
563
|
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 || "..."})`;
|
|
371
567
|
return h.handler || "unknown";
|
|
372
568
|
}).join(" \u2192 ");
|
|
373
|
-
lines.push(` Route ${i}
|
|
569
|
+
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
|
|
374
570
|
}
|
|
375
571
|
return {
|
|
376
572
|
content: [
|
|
@@ -384,10 +580,20 @@ function registerRouteTools(server) {
|
|
|
384
580
|
|
|
385
581
|
// src/tools/tls.ts
|
|
386
582
|
import { z as z5 } from "zod";
|
|
583
|
+
function buildTlsConfig(fields) {
|
|
584
|
+
const issuer = { module: "acme" };
|
|
585
|
+
if (fields.email) issuer.email = fields.email;
|
|
586
|
+
if (fields.ca) issuer.ca = fields.ca;
|
|
587
|
+
return {
|
|
588
|
+
automation: {
|
|
589
|
+
policies: [{ issuers: [issuer] }]
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
387
593
|
function registerTlsTools(server) {
|
|
388
594
|
server.tool(
|
|
389
595
|
"caddy_tls",
|
|
390
|
-
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL.",
|
|
596
|
+
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL. Works on both fresh and existing Caddy instances.",
|
|
391
597
|
{
|
|
392
598
|
action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
|
|
393
599
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
@@ -406,7 +612,9 @@ function registerTlsTools(server) {
|
|
|
406
612
|
};
|
|
407
613
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
408
614
|
if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
409
|
-
|
|
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);
|
|
410
618
|
}
|
|
411
619
|
if (action === "set_acme_ca") {
|
|
412
620
|
if (!ca)
|
|
@@ -416,7 +624,9 @@ function registerTlsTools(server) {
|
|
|
416
624
|
};
|
|
417
625
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
418
626
|
if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
419
|
-
|
|
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);
|
|
420
630
|
}
|
|
421
631
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
422
632
|
}
|
|
@@ -441,13 +651,6 @@ async function startServer() {
|
|
|
441
651
|
const transport = new StdioServerTransport();
|
|
442
652
|
await server.connect(transport);
|
|
443
653
|
}
|
|
444
|
-
var isDirectRun = process.argv[1]?.endsWith("server.js");
|
|
445
|
-
if (isDirectRun) {
|
|
446
|
-
startServer().catch((err) => {
|
|
447
|
-
console.error("MCP server error:", err);
|
|
448
|
-
process.exit(1);
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
654
|
export {
|
|
452
655
|
createCaddyServer,
|
|
453
656
|
startServer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for managing Caddy web servers via the admin API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
|
|
@@ -31,18 +31,19 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit",
|
|
32
32
|
"test:ci": "npm run build && npm test",
|
|
33
33
|
"prepublishOnly": "npm run build",
|
|
34
|
+
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
|
|
34
35
|
"start": "node dist/index.js"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
-
"zod": "^3.
|
|
39
|
+
"zod": "^4.3.6"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@biomejs/biome": "^
|
|
42
|
-
"@types/node": "^25.
|
|
42
|
+
"@biomejs/biome": "^2.4.11",
|
|
43
|
+
"@types/node": "^25.6.0",
|
|
43
44
|
"tsup": "^8.4.0",
|
|
44
|
-
"typescript": "^
|
|
45
|
-
"vitest": "^
|
|
45
|
+
"typescript": "^6.0.2",
|
|
46
|
+
"vitest": "^4.1.4"
|
|
46
47
|
},
|
|
47
48
|
"engines": {
|
|
48
49
|
"node": ">=18"
|