@yawlabs/caddy-mcp 0.1.0 → 0.1.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 +4 -1
- package/dist/index.js +247 -48
- package/dist/server.js +247 -48
- package/package.json +6 -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,45 @@ 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
|
+
},
|
|
276
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
277
|
+
async ({ id, action, value, subpath }) => {
|
|
278
|
+
if (action === "get") {
|
|
279
|
+
return formatResult(await configByIdGet(id, subpath));
|
|
280
|
+
}
|
|
281
|
+
if (action === "set") {
|
|
282
|
+
if (value === void 0) {
|
|
283
|
+
return {
|
|
284
|
+
isError: true,
|
|
285
|
+
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return formatResult(await configByIdSet(id, value, "PATCH", subpath));
|
|
289
|
+
}
|
|
290
|
+
if (action === "delete") {
|
|
291
|
+
return formatResult(await configByIdDelete(id, subpath));
|
|
292
|
+
}
|
|
293
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
294
|
+
}
|
|
191
295
|
);
|
|
192
296
|
}
|
|
193
297
|
|
|
@@ -214,7 +318,9 @@ function registerOperationalTools(server) {
|
|
|
214
318
|
const srv = servers[name];
|
|
215
319
|
const listen = srv.listen || [];
|
|
216
320
|
const routes = srv.routes || [];
|
|
217
|
-
const
|
|
321
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
322
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
323
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
218
324
|
lines.push(
|
|
219
325
|
`Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
|
|
220
326
|
);
|
|
@@ -229,6 +335,35 @@ ACME email: ${email}`);
|
|
|
229
335
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
230
336
|
}
|
|
231
337
|
);
|
|
338
|
+
server.tool(
|
|
339
|
+
"caddy_list_servers",
|
|
340
|
+
"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.",
|
|
341
|
+
{},
|
|
342
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
343
|
+
async () => {
|
|
344
|
+
const res = await configGet("apps/http/servers");
|
|
345
|
+
if (!res.ok) return formatResult(res);
|
|
346
|
+
const servers = res.data || {};
|
|
347
|
+
const names = Object.keys(servers);
|
|
348
|
+
if (names.length === 0) {
|
|
349
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
350
|
+
}
|
|
351
|
+
const lines = [];
|
|
352
|
+
for (const name of names) {
|
|
353
|
+
const srv = servers[name];
|
|
354
|
+
const listen = srv.listen || [];
|
|
355
|
+
const routes = srv.routes || [];
|
|
356
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
357
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
358
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
359
|
+
lines.push(` ${name}: ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
content: [{ type: "text", text: `HTTP Servers:
|
|
363
|
+
${lines.join("\n")}` }]
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
);
|
|
232
367
|
server.tool(
|
|
233
368
|
"caddy_upstreams",
|
|
234
369
|
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
@@ -240,7 +375,7 @@ ACME email: ${email}`);
|
|
|
240
375
|
"caddy_pki",
|
|
241
376
|
"Get PKI certificate authority info or the CA certificate chain.",
|
|
242
377
|
{
|
|
243
|
-
ca: z3.string().optional().default("local").describe("CA ID (default: 'local')"),
|
|
378
|
+
ca: z3.string().regex(/^[\w-]+$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
244
379
|
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
245
380
|
},
|
|
246
381
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -249,6 +384,13 @@ ACME email: ${email}`);
|
|
|
249
384
|
return formatResult(res);
|
|
250
385
|
}
|
|
251
386
|
);
|
|
387
|
+
server.tool(
|
|
388
|
+
"caddy_metrics",
|
|
389
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
|
|
390
|
+
{},
|
|
391
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
392
|
+
async () => formatResult(await getMetrics())
|
|
393
|
+
);
|
|
252
394
|
server.tool(
|
|
253
395
|
"caddy_stop",
|
|
254
396
|
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
@@ -269,18 +411,33 @@ ACME email: ${email}`);
|
|
|
269
411
|
// src/tools/routes.ts
|
|
270
412
|
import { z as z4 } from "zod";
|
|
271
413
|
function parseFrom(from) {
|
|
414
|
+
const cleaned = from.replace(/^https?:\/\//, "");
|
|
272
415
|
const match = {};
|
|
273
|
-
const slashIdx =
|
|
416
|
+
const slashIdx = cleaned.indexOf("/");
|
|
274
417
|
if (slashIdx > 0) {
|
|
275
|
-
match.host = [
|
|
276
|
-
match.path = [
|
|
277
|
-
} else if (
|
|
278
|
-
match.path = [
|
|
418
|
+
match.host = [cleaned.substring(0, slashIdx)];
|
|
419
|
+
match.path = [cleaned.substring(slashIdx)];
|
|
420
|
+
} else if (cleaned.startsWith("/")) {
|
|
421
|
+
match.path = [cleaned];
|
|
279
422
|
} else {
|
|
280
|
-
match.host = [
|
|
423
|
+
match.host = [cleaned];
|
|
281
424
|
}
|
|
282
425
|
return match;
|
|
283
426
|
}
|
|
427
|
+
function cleanUpstreamAddr(addr) {
|
|
428
|
+
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
429
|
+
}
|
|
430
|
+
function serverNotFoundError(srv) {
|
|
431
|
+
return {
|
|
432
|
+
isError: true,
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: "text",
|
|
436
|
+
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"] }`
|
|
437
|
+
}
|
|
438
|
+
]
|
|
439
|
+
};
|
|
440
|
+
}
|
|
284
441
|
function registerRouteTools(server) {
|
|
285
442
|
server.tool(
|
|
286
443
|
"caddy_reverse_proxy",
|
|
@@ -288,24 +445,28 @@ function registerRouteTools(server) {
|
|
|
288
445
|
{
|
|
289
446
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
290
447
|
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)")
|
|
448
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
292
449
|
},
|
|
293
450
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
294
451
|
async ({ from, to, server: srv }) => {
|
|
295
452
|
const match = parseFrom(from);
|
|
453
|
+
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
296
454
|
const route = {
|
|
297
455
|
match: [match],
|
|
298
456
|
handle: [
|
|
299
457
|
{
|
|
300
458
|
handler: "reverse_proxy",
|
|
301
|
-
upstreams:
|
|
459
|
+
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
302
460
|
}
|
|
303
461
|
],
|
|
304
462
|
terminal: true
|
|
305
463
|
};
|
|
306
464
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
307
465
|
if (res.ok) {
|
|
308
|
-
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${
|
|
466
|
+
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
|
|
467
|
+
}
|
|
468
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
469
|
+
return serverNotFoundError(srv);
|
|
309
470
|
}
|
|
310
471
|
return formatResult(res);
|
|
311
472
|
}
|
|
@@ -314,15 +475,18 @@ function registerRouteTools(server) {
|
|
|
314
475
|
"caddy_add_route",
|
|
315
476
|
"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
477
|
{
|
|
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)"),
|
|
478
|
+
match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
479
|
+
handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
|
|
480
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
320
481
|
terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
|
|
321
482
|
},
|
|
322
483
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
323
484
|
async ({ match, handle, server: srv, terminal }) => {
|
|
324
485
|
const route = { match, handle, terminal };
|
|
325
486
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
487
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
488
|
+
return serverNotFoundError(srv);
|
|
489
|
+
}
|
|
326
490
|
return formatResult(res);
|
|
327
491
|
}
|
|
328
492
|
);
|
|
@@ -330,7 +494,7 @@ function registerRouteTools(server) {
|
|
|
330
494
|
"caddy_list_routes",
|
|
331
495
|
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
332
496
|
{
|
|
333
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
497
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
334
498
|
},
|
|
335
499
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
336
500
|
async ({ server: srv }) => {
|
|
@@ -352,12 +516,35 @@ function registerRouteTools(server) {
|
|
|
352
516
|
const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
|
|
353
517
|
for (let i = 0; i < routes.length; i++) {
|
|
354
518
|
const route = routes[i];
|
|
519
|
+
const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
|
|
520
|
+
const group = route.group ? ` group="${route.group}"` : "";
|
|
355
521
|
const matchers = (route.match || []).map((m) => {
|
|
356
522
|
const parts = [];
|
|
357
523
|
if (m.host) parts.push(`host=[${m.host.join(",")}]`);
|
|
358
524
|
if (m.path) parts.push(`path=[${m.path.join(",")}]`);
|
|
359
525
|
if (m.method) parts.push(`method=[${m.method.join(",")}]`);
|
|
526
|
+
if (m.protocol) parts.push(`protocol=${m.protocol}`);
|
|
527
|
+
if (m.remote_ip) parts.push(`remote_ip=[${m.remote_ip.ranges?.join(",") || "..."}]`);
|
|
528
|
+
if (m.client_ip) parts.push(`client_ip=[${m.client_ip.ranges?.join(",") || "..."}]`);
|
|
529
|
+
if (m.query) parts.push("query=...");
|
|
360
530
|
if (m.header) parts.push("header=...");
|
|
531
|
+
if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
|
|
532
|
+
if (m.not) parts.push("not(...)");
|
|
533
|
+
const known = /* @__PURE__ */ new Set([
|
|
534
|
+
"host",
|
|
535
|
+
"path",
|
|
536
|
+
"method",
|
|
537
|
+
"protocol",
|
|
538
|
+
"remote_ip",
|
|
539
|
+
"client_ip",
|
|
540
|
+
"query",
|
|
541
|
+
"header",
|
|
542
|
+
"expression",
|
|
543
|
+
"not"
|
|
544
|
+
]);
|
|
545
|
+
for (const key of Object.keys(m)) {
|
|
546
|
+
if (!known.has(key)) parts.push(`${key}=...`);
|
|
547
|
+
}
|
|
361
548
|
if (parts.length === 0) return "catch-all";
|
|
362
549
|
return parts.join(" ");
|
|
363
550
|
}).join(" | ");
|
|
@@ -368,11 +555,16 @@ function registerRouteTools(server) {
|
|
|
368
555
|
}
|
|
369
556
|
if (h.handler === "file_server") return `file_server(${h.root || "."})`;
|
|
370
557
|
if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
|
|
558
|
+
if (h.handler === "rewrite") return `rewrite(${h.uri || "..."})`;
|
|
559
|
+
if (h.handler === "subroute") return `subroute(${h.routes?.length || 0} routes)`;
|
|
371
560
|
if (h.handler === "encode") return "encode";
|
|
372
561
|
if (h.handler === "headers") return "headers";
|
|
562
|
+
if (h.handler === "authentication")
|
|
563
|
+
return `auth(${h.providers ? Object.keys(h.providers).join(",") : "..."})`;
|
|
564
|
+
if (h.handler === "error") return `error(${h.status_code || "..."})`;
|
|
373
565
|
return h.handler || "unknown";
|
|
374
566
|
}).join(" \u2192 ");
|
|
375
|
-
lines.push(` Route ${i}
|
|
567
|
+
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
|
|
376
568
|
}
|
|
377
569
|
return {
|
|
378
570
|
content: [
|
|
@@ -386,10 +578,20 @@ function registerRouteTools(server) {
|
|
|
386
578
|
|
|
387
579
|
// src/tools/tls.ts
|
|
388
580
|
import { z as z5 } from "zod";
|
|
581
|
+
function buildTlsConfig(fields) {
|
|
582
|
+
const issuer = { module: "acme" };
|
|
583
|
+
if (fields.email) issuer.email = fields.email;
|
|
584
|
+
if (fields.ca) issuer.ca = fields.ca;
|
|
585
|
+
return {
|
|
586
|
+
automation: {
|
|
587
|
+
policies: [{ issuers: [issuer] }]
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
389
591
|
function registerTlsTools(server) {
|
|
390
592
|
server.tool(
|
|
391
593
|
"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.",
|
|
594
|
+
"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
595
|
{
|
|
394
596
|
action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
|
|
395
597
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
@@ -408,7 +610,9 @@ function registerTlsTools(server) {
|
|
|
408
610
|
};
|
|
409
611
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
410
612
|
if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
411
|
-
|
|
613
|
+
const fallback = await configPost("apps/tls", buildTlsConfig({ email }));
|
|
614
|
+
if (fallback.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
615
|
+
return formatResult(fallback);
|
|
412
616
|
}
|
|
413
617
|
if (action === "set_acme_ca") {
|
|
414
618
|
if (!ca)
|
|
@@ -418,7 +622,9 @@ function registerTlsTools(server) {
|
|
|
418
622
|
};
|
|
419
623
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
420
624
|
if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
421
|
-
|
|
625
|
+
const fallback = await configPost("apps/tls", buildTlsConfig({ ca }));
|
|
626
|
+
if (fallback.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
627
|
+
return formatResult(fallback);
|
|
422
628
|
}
|
|
423
629
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
424
630
|
}
|
|
@@ -443,13 +649,6 @@ async function startServer() {
|
|
|
443
649
|
const transport = new StdioServerTransport();
|
|
444
650
|
await server.connect(transport);
|
|
445
651
|
}
|
|
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
652
|
|
|
454
653
|
// src/index.ts
|
|
455
654
|
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,45 @@ 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
|
+
},
|
|
274
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
275
|
+
async ({ id, action, value, subpath }) => {
|
|
276
|
+
if (action === "get") {
|
|
277
|
+
return formatResult(await configByIdGet(id, subpath));
|
|
278
|
+
}
|
|
279
|
+
if (action === "set") {
|
|
280
|
+
if (value === void 0) {
|
|
281
|
+
return {
|
|
282
|
+
isError: true,
|
|
283
|
+
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return formatResult(await configByIdSet(id, value, "PATCH", subpath));
|
|
287
|
+
}
|
|
288
|
+
if (action === "delete") {
|
|
289
|
+
return formatResult(await configByIdDelete(id, subpath));
|
|
290
|
+
}
|
|
291
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
292
|
+
}
|
|
189
293
|
);
|
|
190
294
|
}
|
|
191
295
|
|
|
@@ -212,7 +316,9 @@ function registerOperationalTools(server) {
|
|
|
212
316
|
const srv = servers[name];
|
|
213
317
|
const listen = srv.listen || [];
|
|
214
318
|
const routes = srv.routes || [];
|
|
215
|
-
const
|
|
319
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
320
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
321
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
216
322
|
lines.push(
|
|
217
323
|
`Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
|
|
218
324
|
);
|
|
@@ -227,6 +333,35 @@ ACME email: ${email}`);
|
|
|
227
333
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
228
334
|
}
|
|
229
335
|
);
|
|
336
|
+
server.tool(
|
|
337
|
+
"caddy_list_servers",
|
|
338
|
+
"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.",
|
|
339
|
+
{},
|
|
340
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
341
|
+
async () => {
|
|
342
|
+
const res = await configGet("apps/http/servers");
|
|
343
|
+
if (!res.ok) return formatResult(res);
|
|
344
|
+
const servers = res.data || {};
|
|
345
|
+
const names = Object.keys(servers);
|
|
346
|
+
if (names.length === 0) {
|
|
347
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
348
|
+
}
|
|
349
|
+
const lines = [];
|
|
350
|
+
for (const name of names) {
|
|
351
|
+
const srv = servers[name];
|
|
352
|
+
const listen = srv.listen || [];
|
|
353
|
+
const routes = srv.routes || [];
|
|
354
|
+
const hasExplicitTls = !!srv.tls_connection_policies;
|
|
355
|
+
const listensHttps = listen.some((l) => l.includes(":443"));
|
|
356
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
357
|
+
lines.push(` ${name}: ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`);
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
content: [{ type: "text", text: `HTTP Servers:
|
|
361
|
+
${lines.join("\n")}` }]
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
);
|
|
230
365
|
server.tool(
|
|
231
366
|
"caddy_upstreams",
|
|
232
367
|
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
@@ -238,7 +373,7 @@ ACME email: ${email}`);
|
|
|
238
373
|
"caddy_pki",
|
|
239
374
|
"Get PKI certificate authority info or the CA certificate chain.",
|
|
240
375
|
{
|
|
241
|
-
ca: z3.string().optional().default("local").describe("CA ID (default: 'local')"),
|
|
376
|
+
ca: z3.string().regex(/^[\w-]+$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
242
377
|
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
243
378
|
},
|
|
244
379
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -247,6 +382,13 @@ ACME email: ${email}`);
|
|
|
247
382
|
return formatResult(res);
|
|
248
383
|
}
|
|
249
384
|
);
|
|
385
|
+
server.tool(
|
|
386
|
+
"caddy_metrics",
|
|
387
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
|
|
388
|
+
{},
|
|
389
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
390
|
+
async () => formatResult(await getMetrics())
|
|
391
|
+
);
|
|
250
392
|
server.tool(
|
|
251
393
|
"caddy_stop",
|
|
252
394
|
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
@@ -267,18 +409,33 @@ ACME email: ${email}`);
|
|
|
267
409
|
// src/tools/routes.ts
|
|
268
410
|
import { z as z4 } from "zod";
|
|
269
411
|
function parseFrom(from) {
|
|
412
|
+
const cleaned = from.replace(/^https?:\/\//, "");
|
|
270
413
|
const match = {};
|
|
271
|
-
const slashIdx =
|
|
414
|
+
const slashIdx = cleaned.indexOf("/");
|
|
272
415
|
if (slashIdx > 0) {
|
|
273
|
-
match.host = [
|
|
274
|
-
match.path = [
|
|
275
|
-
} else if (
|
|
276
|
-
match.path = [
|
|
416
|
+
match.host = [cleaned.substring(0, slashIdx)];
|
|
417
|
+
match.path = [cleaned.substring(slashIdx)];
|
|
418
|
+
} else if (cleaned.startsWith("/")) {
|
|
419
|
+
match.path = [cleaned];
|
|
277
420
|
} else {
|
|
278
|
-
match.host = [
|
|
421
|
+
match.host = [cleaned];
|
|
279
422
|
}
|
|
280
423
|
return match;
|
|
281
424
|
}
|
|
425
|
+
function cleanUpstreamAddr(addr) {
|
|
426
|
+
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
427
|
+
}
|
|
428
|
+
function serverNotFoundError(srv) {
|
|
429
|
+
return {
|
|
430
|
+
isError: true,
|
|
431
|
+
content: [
|
|
432
|
+
{
|
|
433
|
+
type: "text",
|
|
434
|
+
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"] }`
|
|
435
|
+
}
|
|
436
|
+
]
|
|
437
|
+
};
|
|
438
|
+
}
|
|
282
439
|
function registerRouteTools(server) {
|
|
283
440
|
server.tool(
|
|
284
441
|
"caddy_reverse_proxy",
|
|
@@ -286,24 +443,28 @@ function registerRouteTools(server) {
|
|
|
286
443
|
{
|
|
287
444
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
288
445
|
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)")
|
|
446
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
290
447
|
},
|
|
291
448
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
292
449
|
async ({ from, to, server: srv }) => {
|
|
293
450
|
const match = parseFrom(from);
|
|
451
|
+
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
294
452
|
const route = {
|
|
295
453
|
match: [match],
|
|
296
454
|
handle: [
|
|
297
455
|
{
|
|
298
456
|
handler: "reverse_proxy",
|
|
299
|
-
upstreams:
|
|
457
|
+
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
300
458
|
}
|
|
301
459
|
],
|
|
302
460
|
terminal: true
|
|
303
461
|
};
|
|
304
462
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
305
463
|
if (res.ok) {
|
|
306
|
-
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${
|
|
464
|
+
return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
|
|
465
|
+
}
|
|
466
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
467
|
+
return serverNotFoundError(srv);
|
|
307
468
|
}
|
|
308
469
|
return formatResult(res);
|
|
309
470
|
}
|
|
@@ -312,15 +473,18 @@ function registerRouteTools(server) {
|
|
|
312
473
|
"caddy_add_route",
|
|
313
474
|
"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
475
|
{
|
|
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)"),
|
|
476
|
+
match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
477
|
+
handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
|
|
478
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
318
479
|
terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
|
|
319
480
|
},
|
|
320
481
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
321
482
|
async ({ match, handle, server: srv, terminal }) => {
|
|
322
483
|
const route = { match, handle, terminal };
|
|
323
484
|
const res = await configPost(`apps/http/servers/${srv}/routes`, route);
|
|
485
|
+
if (!res.ok && res.error?.includes("key does not exist")) {
|
|
486
|
+
return serverNotFoundError(srv);
|
|
487
|
+
}
|
|
324
488
|
return formatResult(res);
|
|
325
489
|
}
|
|
326
490
|
);
|
|
@@ -328,7 +492,7 @@ function registerRouteTools(server) {
|
|
|
328
492
|
"caddy_list_routes",
|
|
329
493
|
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
330
494
|
{
|
|
331
|
-
server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
495
|
+
server: z4.string().regex(/^[\w-]+$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
332
496
|
},
|
|
333
497
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
334
498
|
async ({ server: srv }) => {
|
|
@@ -350,12 +514,35 @@ function registerRouteTools(server) {
|
|
|
350
514
|
const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
|
|
351
515
|
for (let i = 0; i < routes.length; i++) {
|
|
352
516
|
const route = routes[i];
|
|
517
|
+
const id = route["@id"] ? ` @id="${route["@id"]}"` : "";
|
|
518
|
+
const group = route.group ? ` group="${route.group}"` : "";
|
|
353
519
|
const matchers = (route.match || []).map((m) => {
|
|
354
520
|
const parts = [];
|
|
355
521
|
if (m.host) parts.push(`host=[${m.host.join(",")}]`);
|
|
356
522
|
if (m.path) parts.push(`path=[${m.path.join(",")}]`);
|
|
357
523
|
if (m.method) parts.push(`method=[${m.method.join(",")}]`);
|
|
524
|
+
if (m.protocol) parts.push(`protocol=${m.protocol}`);
|
|
525
|
+
if (m.remote_ip) parts.push(`remote_ip=[${m.remote_ip.ranges?.join(",") || "..."}]`);
|
|
526
|
+
if (m.client_ip) parts.push(`client_ip=[${m.client_ip.ranges?.join(",") || "..."}]`);
|
|
527
|
+
if (m.query) parts.push("query=...");
|
|
358
528
|
if (m.header) parts.push("header=...");
|
|
529
|
+
if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
|
|
530
|
+
if (m.not) parts.push("not(...)");
|
|
531
|
+
const known = /* @__PURE__ */ new Set([
|
|
532
|
+
"host",
|
|
533
|
+
"path",
|
|
534
|
+
"method",
|
|
535
|
+
"protocol",
|
|
536
|
+
"remote_ip",
|
|
537
|
+
"client_ip",
|
|
538
|
+
"query",
|
|
539
|
+
"header",
|
|
540
|
+
"expression",
|
|
541
|
+
"not"
|
|
542
|
+
]);
|
|
543
|
+
for (const key of Object.keys(m)) {
|
|
544
|
+
if (!known.has(key)) parts.push(`${key}=...`);
|
|
545
|
+
}
|
|
359
546
|
if (parts.length === 0) return "catch-all";
|
|
360
547
|
return parts.join(" ");
|
|
361
548
|
}).join(" | ");
|
|
@@ -366,11 +553,16 @@ function registerRouteTools(server) {
|
|
|
366
553
|
}
|
|
367
554
|
if (h.handler === "file_server") return `file_server(${h.root || "."})`;
|
|
368
555
|
if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
|
|
556
|
+
if (h.handler === "rewrite") return `rewrite(${h.uri || "..."})`;
|
|
557
|
+
if (h.handler === "subroute") return `subroute(${h.routes?.length || 0} routes)`;
|
|
369
558
|
if (h.handler === "encode") return "encode";
|
|
370
559
|
if (h.handler === "headers") return "headers";
|
|
560
|
+
if (h.handler === "authentication")
|
|
561
|
+
return `auth(${h.providers ? Object.keys(h.providers).join(",") : "..."})`;
|
|
562
|
+
if (h.handler === "error") return `error(${h.status_code || "..."})`;
|
|
371
563
|
return h.handler || "unknown";
|
|
372
564
|
}).join(" \u2192 ");
|
|
373
|
-
lines.push(` Route ${i}
|
|
565
|
+
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
|
|
374
566
|
}
|
|
375
567
|
return {
|
|
376
568
|
content: [
|
|
@@ -384,10 +576,20 @@ function registerRouteTools(server) {
|
|
|
384
576
|
|
|
385
577
|
// src/tools/tls.ts
|
|
386
578
|
import { z as z5 } from "zod";
|
|
579
|
+
function buildTlsConfig(fields) {
|
|
580
|
+
const issuer = { module: "acme" };
|
|
581
|
+
if (fields.email) issuer.email = fields.email;
|
|
582
|
+
if (fields.ca) issuer.ca = fields.ca;
|
|
583
|
+
return {
|
|
584
|
+
automation: {
|
|
585
|
+
policies: [{ issuers: [issuer] }]
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
387
589
|
function registerTlsTools(server) {
|
|
388
590
|
server.tool(
|
|
389
591
|
"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.",
|
|
592
|
+
"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
593
|
{
|
|
392
594
|
action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
|
|
393
595
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
@@ -406,7 +608,9 @@ function registerTlsTools(server) {
|
|
|
406
608
|
};
|
|
407
609
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
408
610
|
if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
409
|
-
|
|
611
|
+
const fallback = await configPost("apps/tls", buildTlsConfig({ email }));
|
|
612
|
+
if (fallback.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
613
|
+
return formatResult(fallback);
|
|
410
614
|
}
|
|
411
615
|
if (action === "set_acme_ca") {
|
|
412
616
|
if (!ca)
|
|
@@ -416,7 +620,9 @@ function registerTlsTools(server) {
|
|
|
416
620
|
};
|
|
417
621
|
const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
418
622
|
if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
419
|
-
|
|
623
|
+
const fallback = await configPost("apps/tls", buildTlsConfig({ ca }));
|
|
624
|
+
if (fallback.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
625
|
+
return formatResult(fallback);
|
|
420
626
|
}
|
|
421
627
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
422
628
|
}
|
|
@@ -441,13 +647,6 @@ async function startServer() {
|
|
|
441
647
|
const transport = new StdioServerTransport();
|
|
442
648
|
await server.connect(transport);
|
|
443
649
|
}
|
|
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
650
|
export {
|
|
452
651
|
createCaddyServer,
|
|
453
652
|
startServer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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)",
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
-
"zod": "^3.
|
|
38
|
+
"zod": "^4.3.6"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@biomejs/biome": "^
|
|
42
|
-
"@types/node": "^25.
|
|
41
|
+
"@biomejs/biome": "^2.4.11",
|
|
42
|
+
"@types/node": "^25.6.0",
|
|
43
43
|
"tsup": "^8.4.0",
|
|
44
|
-
"typescript": "^
|
|
45
|
-
"vitest": "^
|
|
44
|
+
"typescript": "^6.0.2",
|
|
45
|
+
"vitest": "^4.1.4"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
48
|
"node": ">=18"
|