@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- **MCP server for managing Caddy web servers.** 13 tools for config management, reverse proxy setup, route operations, TLS, and server monitoring — all via Caddy's admin API.
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 = "application/json") {
15
- const headers = { "Content-Type": contentType };
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: getHeaders(contentType),
29
- body: body !== void 0 ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
30
- signal: AbortSignal.timeout(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 ${TIMEOUT}ms` };
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
- function loadConfig(config) {
74
- return caddyRequest("POST", "/load", config);
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 text = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "OK";
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 }) => formatResult(await adapt(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
- "Create or replace config at a JSON path. Mode 'create' (default) appends to arrays or creates objects (POST). Mode 'replace' overwrites existing values (PATCH).",
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(["create", "replace"]).optional().default("create").describe("'create' = POST (append), 'replace' = PATCH (overwrite)")
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 === "replace" ? await configPatch(path, value) : await configPost(path, value);
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 full Caddy JSON config object. This is the safest way to make large config changes.",
188
- { config: z2.record(z2.any()).describe("Full Caddy JSON configuration object") },
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 }) => formatResult(await loadConfig(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 tls = srv.tls_connection_policies ? "enabled" : "auto";
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 = from.indexOf("/");
420
+ const slashIdx = cleaned.indexOf("/");
274
421
  if (slashIdx > 0) {
275
- match.host = [from.substring(0, slashIdx)];
276
- match.path = [from.substring(slashIdx)];
277
- } else if (from.startsWith("/")) {
278
- match.path = [from];
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 = [from];
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: to.map((addr) => ({ dial: addr }))
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 ${to.join(", ")}` }] };
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}: ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
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
- return formatResult(res);
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
- return formatResult(res);
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 = "application/json") {
13
- const headers = { "Content-Type": contentType };
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: getHeaders(contentType),
27
- body: body !== void 0 ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
28
- signal: AbortSignal.timeout(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 ${TIMEOUT}ms` };
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
- function loadConfig(config) {
72
- return caddyRequest("POST", "/load", config);
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 text = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "OK";
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 }) => formatResult(await adapt(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
- "Create or replace config at a JSON path. Mode 'create' (default) appends to arrays or creates objects (POST). Mode 'replace' overwrites existing values (PATCH).",
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(["create", "replace"]).optional().default("create").describe("'create' = POST (append), 'replace' = PATCH (overwrite)")
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 === "replace" ? await configPatch(path, value) : await configPost(path, value);
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 full Caddy JSON config object. This is the safest way to make large config changes.",
186
- { config: z2.record(z2.any()).describe("Full Caddy JSON configuration object") },
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 }) => formatResult(await loadConfig(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 tls = srv.tls_connection_policies ? "enabled" : "auto";
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 = from.indexOf("/");
418
+ const slashIdx = cleaned.indexOf("/");
272
419
  if (slashIdx > 0) {
273
- match.host = [from.substring(0, slashIdx)];
274
- match.path = [from.substring(slashIdx)];
275
- } else if (from.startsWith("/")) {
276
- match.path = [from];
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 = [from];
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: to.map((addr) => ({ dial: addr }))
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 ${to.join(", ")}` }] };
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}: ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
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
- return formatResult(res);
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
- return formatResult(res);
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.1.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.24.4"
39
+ "zod": "^4.3.6"
39
40
  },
40
41
  "devDependencies": {
41
- "@biomejs/biome": "^1.9.4",
42
- "@types/node": "^25.5.2",
42
+ "@biomejs/biome": "^2.4.11",
43
+ "@types/node": "^25.6.0",
43
44
  "tsup": "^8.4.0",
44
- "typescript": "^5.8.3",
45
- "vitest": "^3.1.1"
45
+ "typescript": "^6.0.2",
46
+ "vitest": "^4.1.4"
46
47
  },
47
48
  "engines": {
48
49
  "node": ">=18"