@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 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,45 @@ 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
+ },
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 tls = srv.tls_connection_policies ? "enabled" : "auto";
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 = from.indexOf("/");
416
+ const slashIdx = cleaned.indexOf("/");
274
417
  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];
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 = [from];
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: to.map((addr) => ({ dial: addr }))
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 ${to.join(", ")}` }] };
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}: ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
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
- return formatResult(res);
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
- return formatResult(res);
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 = "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,45 @@ 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
+ },
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 tls = srv.tls_connection_policies ? "enabled" : "auto";
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 = from.indexOf("/");
414
+ const slashIdx = cleaned.indexOf("/");
272
415
  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];
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 = [from];
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: to.map((addr) => ({ dial: addr }))
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 ${to.join(", ")}` }] };
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}: ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
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
- return formatResult(res);
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
- return formatResult(res);
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.0",
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.24.4"
38
+ "zod": "^4.3.6"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "^1.9.4",
42
- "@types/node": "^25.5.2",
41
+ "@biomejs/biome": "^2.4.11",
42
+ "@types/node": "^25.6.0",
43
43
  "tsup": "^8.4.0",
44
- "typescript": "^5.8.3",
45
- "vitest": "^3.1.1"
44
+ "typescript": "^6.0.2",
45
+ "vitest": "^4.1.4"
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=18"