@yawlabs/caddy-mcp 1.3.0 → 2.0.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/dist/api.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface ApiResponse<T = any> {
2
+ ok: boolean;
3
+ status: number;
4
+ data?: T;
5
+ error?: string;
6
+ etag?: string;
7
+ }
8
+ export declare function configGet<T = any>(path?: string): Promise<ApiResponse<T>>;
9
+ export declare function configPost<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
10
+ export declare function configPut<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
11
+ export declare function configPatch<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
12
+ export declare function configDelete<T = any>(path: string): Promise<ApiResponse<T>>;
13
+ export declare function loadConfig(config: unknown, contentType?: string): Promise<ApiResponse>;
14
+ export declare function adapt<T = any>(config: string, adapter?: string): Promise<ApiResponse<T>>;
15
+ export declare function stop(): Promise<ApiResponse>;
16
+ export declare function getUpstreams(): Promise<ApiResponse>;
17
+ export declare function getPki(ca?: string): Promise<ApiResponse>;
18
+ export declare function getPkiCertificates(ca?: string): Promise<ApiResponse>;
19
+ export declare function configByIdGet<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
20
+ export declare function configByIdSet<T = any>(id: string, value: unknown, method?: "POST" | "PATCH" | "PUT", subpath?: string): Promise<ApiResponse<T>>;
21
+ export declare function configByIdDelete<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
22
+ export declare function getMetrics(): Promise<ApiResponse>;
@@ -0,0 +1,15 @@
1
+ import type { ApiResponse } from "./api.js";
2
+ /** Convert an API response to MCP tool result format */
3
+ export declare function formatResult(res: ApiResponse): {
4
+ isError: boolean;
5
+ content: {
6
+ type: "text";
7
+ text: string;
8
+ }[];
9
+ } | {
10
+ isError?: undefined;
11
+ content: {
12
+ type: "text";
13
+ text: string;
14
+ }[];
15
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js CHANGED
@@ -21,11 +21,15 @@ function setEtag(path, etag) {
21
21
  }
22
22
  etagCache.set(path, etag);
23
23
  }
24
+ function isAncestorOf(ancestor, descendant) {
25
+ const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
26
+ return descendant.startsWith(`${base}/`);
27
+ }
24
28
  function invalidateRelated(path) {
25
29
  etagCache.delete(path);
26
30
  for (const key of Array.from(etagCache.keys())) {
27
31
  if (key === path) continue;
28
- if (path.startsWith(`${key}/`) || key.startsWith(`${path}/`)) {
32
+ if (isAncestorOf(key, path) || isAncestorOf(path, key)) {
29
33
  etagCache.delete(key);
30
34
  }
31
35
  }
@@ -54,11 +58,20 @@ function getMaxRetries() {
54
58
  }
55
59
  return Math.min(floored, RETRY_HARD_CAP);
56
60
  }
61
+ function getAdminOrigin() {
62
+ try {
63
+ return new URL(getBaseUrl()).origin;
64
+ } catch {
65
+ return void 0;
66
+ }
67
+ }
57
68
  function getHeaders(contentType) {
58
69
  const headers = {};
59
70
  if (contentType) headers["Content-Type"] = contentType;
60
71
  const token = process.env.CADDY_API_TOKEN;
61
72
  if (token) headers.Authorization = `Bearer ${token}`;
73
+ const origin = getAdminOrigin();
74
+ if (origin) headers.Origin = origin;
62
75
  return headers;
63
76
  }
64
77
  function normalizePath(path) {
@@ -83,7 +96,9 @@ function isTransientFailure(res) {
83
96
  if (res.status >= 500 && res.status <= 599) return true;
84
97
  return false;
85
98
  }
99
+ var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
86
100
  function isRetryableMethod(method, path) {
101
+ if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
87
102
  if (method !== "POST") return true;
88
103
  return !path.startsWith("/config/") && !path.startsWith("/id/");
89
104
  }
@@ -142,6 +157,13 @@ async function attemptRequest(method, path, body, contentType, timeout) {
142
157
  const hint = res.status === 401 || res.status === 403 ? " -- check CADDY_API_TOKEN" : "";
143
158
  return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
144
159
  }
160
+ if (res.status === 403 && /origin/i.test(text)) {
161
+ return {
162
+ ok: false,
163
+ status: 403,
164
+ error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`
165
+ };
166
+ }
145
167
  return { ok: false, status: res.status, error: text };
146
168
  }
147
169
  if (!text) return { ok: true, status: res.status, etag };
@@ -289,7 +311,8 @@ function formatResult(res) {
289
311
 
290
312
  // src/tools/operational.ts
291
313
  var HTTPS_PORT_RE = /:443(?:\D|$)/;
292
- function describeServer(raw) {
314
+ function describeServer(rawValue) {
315
+ const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
293
316
  const listen = Array.isArray(raw.listen) ? raw.listen : [];
294
317
  const routes = Array.isArray(raw.routes) ? raw.routes : [];
295
318
  const hasExplicitTls = !!raw.tls_connection_policies;
@@ -411,7 +434,7 @@ ${lines.join("\n")}` }]
411
434
  );
412
435
  server.tool(
413
436
  "caddy_metrics",
414
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
437
+ "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines, keeping only '# HELP'/'# TYPE' lines for matching metrics; the '# EOF' end-of-file marker is always preserved. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
415
438
  {
416
439
  filter: z.string().optional().describe(
417
440
  "Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
@@ -639,13 +662,25 @@ function registerConfigTools(server) {
639
662
  );
640
663
  server.tool(
641
664
  "caddy_load",
642
- "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.",
665
+ "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. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
643
666
  {
644
667
  config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
645
- format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
668
+ format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
669
+ confirm: z3.boolean().optional().default(false).describe("Must be true to replace the running configuration (safety)")
646
670
  },
647
671
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
648
- async ({ config, format }) => {
672
+ async ({ config, format, confirm }) => {
673
+ if (!confirm) {
674
+ return {
675
+ isError: true,
676
+ content: [
677
+ {
678
+ type: "text",
679
+ text: "Refusing to replace the running configuration without confirm=true. caddy_load discards every server and route not present in the supplied config. Re-run with confirm:true to proceed (the prior config is snapshotted and restorable via caddy_revert)."
680
+ }
681
+ ]
682
+ };
683
+ }
649
684
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
650
685
  const current = await configGet();
651
686
  const res = await loadConfig(config, contentType);
@@ -782,6 +817,23 @@ ${lines.join("\n")}` }] };
782
817
 
783
818
  // src/tools/routes.ts
784
819
  import { z as z4 } from "zod";
820
+ var ROUTES_JSON_MAX_CHARS = 2e4;
821
+ var ROUTES_SUMMARY_MAX = 500;
822
+ function serializeRoutesCapped(routes) {
823
+ const parts = [];
824
+ let used = 2;
825
+ for (const route of routes) {
826
+ const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
827
+ const cost = entry.length + (parts.length > 0 ? 2 : 1);
828
+ if (parts.length > 0 && used + cost > ROUTES_JSON_MAX_CHARS) break;
829
+ parts.push(entry);
830
+ used += cost;
831
+ }
832
+ if (parts.length === 0) return { json: "[]", shown: 0 };
833
+ return { json: `[
834
+ ${parts.join(",\n")}
835
+ ]`, shown: parts.length };
836
+ }
785
837
  function safeJoin(value) {
786
838
  if (!Array.isArray(value)) return "";
787
839
  return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
@@ -794,6 +846,7 @@ function stripPort(host) {
794
846
  }
795
847
  const colonIdx = host.lastIndexOf(":");
796
848
  if (colonIdx === -1) return host;
849
+ if (host.indexOf(":") !== colonIdx) return host;
797
850
  const portCandidate = host.substring(colonIdx + 1);
798
851
  if (portCandidate.length === 0 || !/^\d+$/.test(portCandidate)) return host;
799
852
  return host.substring(0, colonIdx);
@@ -847,9 +900,9 @@ function serverNotFoundError(srv, op = "operation") {
847
900
  function registerRouteTools(server) {
848
901
  server.tool(
849
902
  "caddy_reverse_proxy",
850
- "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PUT under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
903
+ "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
851
904
  {
852
- from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
905
+ from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
853
906
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
854
907
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
855
908
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
@@ -858,7 +911,18 @@ function registerRouteTools(server) {
858
911
  },
859
912
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
860
913
  async ({ from, to, server: srv, id }) => {
861
- const match = parseFrom(from);
914
+ const match = parseFrom(from.trim());
915
+ if (match.host?.[0]?.trim() === "" || match.host === void 0 && match.path === void 0) {
916
+ return {
917
+ isError: true,
918
+ content: [
919
+ {
920
+ type: "text",
921
+ text: `Error: "from" value ${JSON.stringify(from)} has no host or path to match on. Supply a domain ('api.local'), a path ('/api/*'), or both ('app.local/ws').`
922
+ }
923
+ ]
924
+ };
925
+ }
862
926
  const cleanedTo = to.map(cleanUpstreamAddr);
863
927
  const route = {
864
928
  match: [match],
@@ -885,7 +949,7 @@ function registerRouteTools(server) {
885
949
  ]
886
950
  };
887
951
  }
888
- const putRes = await configByIdSet(id, route, "PUT");
952
+ const putRes = await configByIdSet(id, route, "PATCH");
889
953
  if (putRes.ok) {
890
954
  return {
891
955
  content: [
@@ -948,7 +1012,7 @@ function registerRouteTools(server) {
948
1012
  );
949
1013
  server.tool(
950
1014
  "caddy_list_routes",
951
- "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
1015
+ "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers, followed by the raw route JSON. Both halves are capped on large servers: the summary at 500 routes, the JSON at 20000 characters (truncated on whole-route boundaries, so it always parses). When either cap trims output, a note says how many routes were omitted -- read the rest with caddy_config_get at 'apps/http/servers/<server>/routes'.",
952
1016
  {
953
1017
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
954
1018
  },
@@ -971,7 +1035,8 @@ function registerRouteTools(server) {
971
1035
  };
972
1036
  }
973
1037
  const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
974
- for (let i = 0; i < routes.length; i++) {
1038
+ const summarized = Math.min(routes.length, ROUTES_SUMMARY_MAX);
1039
+ for (let i = 0; i < summarized; i++) {
975
1040
  const rawRoute = routes[i];
976
1041
  if (!rawRoute || typeof rawRoute !== "object") {
977
1042
  lines.push(` Route ${i}: <invalid>`);
@@ -1070,12 +1135,23 @@ function registerRouteTools(server) {
1070
1135
  const terminal = route.terminal === true ? " [terminal]" : "";
1071
1136
  lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
1072
1137
  }
1073
- return {
1074
- content: [
1075
- { type: "text", text: lines.join("\n") },
1076
- { type: "text", text: JSON.stringify(routes, null, 2) }
1077
- ]
1078
- };
1138
+ if (summarized < routes.length) {
1139
+ lines.push(
1140
+ ` ... ${routes.length - summarized} more route(s) not shown (summary caps at ${ROUTES_SUMMARY_MAX}).`
1141
+ );
1142
+ }
1143
+ const { json, shown } = serializeRoutesCapped(routes);
1144
+ const content = [
1145
+ { type: "text", text: lines.join("\n") },
1146
+ { type: "text", text: json }
1147
+ ];
1148
+ if (shown < routes.length) {
1149
+ content.push({
1150
+ type: "text",
1151
+ text: `[JSON block truncated: showing ${shown} of ${routes.length} routes to stay under ${ROUTES_JSON_MAX_CHARS} characters. Read the rest with caddy_config_get at path 'apps/http/servers/${srv}/routes', or one route at a time with caddy_config_by_id.]`
1152
+ });
1153
+ }
1154
+ return { content };
1079
1155
  }
1080
1156
  );
1081
1157
  server.tool(
@@ -1310,8 +1386,11 @@ function registerTlsTools(server) {
1310
1386
  }
1311
1387
 
1312
1388
  // src/server.ts
1313
- var require2 = createRequire(import.meta.url);
1314
- var { version } = require2("../package.json");
1389
+ function resolveVersion() {
1390
+ if (typeof __VERSION__ !== "undefined") return __VERSION__;
1391
+ return createRequire(import.meta.url)("../package.json").version;
1392
+ }
1393
+ var version = resolveVersion();
1315
1394
  function createCaddyServer() {
1316
1395
  const server = new McpServer({ name: "caddy-mcp", version });
1317
1396
  registerConfigTools(server);
@@ -1329,6 +1408,12 @@ async function startServer() {
1329
1408
  }
1330
1409
 
1331
1410
  // src/index.ts
1411
+ var subcommand = process.argv[2];
1412
+ if (subcommand === "--version" || subcommand === "-V") {
1413
+ process.stdout.write(`caddy-mcp ${version}
1414
+ `);
1415
+ process.exit(0);
1416
+ }
1332
1417
  startServer().catch((err) => {
1333
1418
  console.error("caddy-mcp error:", err);
1334
1419
  process.exit(1);
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerResources(server: McpServer): void;
package/dist/server.d.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
-
3
- declare function createCaddyServer(): McpServer;
4
- declare function startServer(): Promise<void>;
5
-
6
- export { createCaddyServer, startServer };
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare const version: string;
3
+ export declare function createCaddyServer(): McpServer;
4
+ export declare function startServer(): Promise<void>;
package/dist/server.js CHANGED
@@ -19,11 +19,15 @@ function setEtag(path, etag) {
19
19
  }
20
20
  etagCache.set(path, etag);
21
21
  }
22
+ function isAncestorOf(ancestor, descendant) {
23
+ const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
24
+ return descendant.startsWith(`${base}/`);
25
+ }
22
26
  function invalidateRelated(path) {
23
27
  etagCache.delete(path);
24
28
  for (const key of Array.from(etagCache.keys())) {
25
29
  if (key === path) continue;
26
- if (path.startsWith(`${key}/`) || key.startsWith(`${path}/`)) {
30
+ if (isAncestorOf(key, path) || isAncestorOf(path, key)) {
27
31
  etagCache.delete(key);
28
32
  }
29
33
  }
@@ -52,11 +56,20 @@ function getMaxRetries() {
52
56
  }
53
57
  return Math.min(floored, RETRY_HARD_CAP);
54
58
  }
59
+ function getAdminOrigin() {
60
+ try {
61
+ return new URL(getBaseUrl()).origin;
62
+ } catch {
63
+ return void 0;
64
+ }
65
+ }
55
66
  function getHeaders(contentType) {
56
67
  const headers = {};
57
68
  if (contentType) headers["Content-Type"] = contentType;
58
69
  const token = process.env.CADDY_API_TOKEN;
59
70
  if (token) headers.Authorization = `Bearer ${token}`;
71
+ const origin = getAdminOrigin();
72
+ if (origin) headers.Origin = origin;
60
73
  return headers;
61
74
  }
62
75
  function normalizePath(path) {
@@ -81,7 +94,9 @@ function isTransientFailure(res) {
81
94
  if (res.status >= 500 && res.status <= 599) return true;
82
95
  return false;
83
96
  }
97
+ var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
84
98
  function isRetryableMethod(method, path) {
99
+ if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
85
100
  if (method !== "POST") return true;
86
101
  return !path.startsWith("/config/") && !path.startsWith("/id/");
87
102
  }
@@ -140,6 +155,13 @@ async function attemptRequest(method, path, body, contentType, timeout) {
140
155
  const hint = res.status === 401 || res.status === 403 ? " -- check CADDY_API_TOKEN" : "";
141
156
  return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
142
157
  }
158
+ if (res.status === 403 && /origin/i.test(text)) {
159
+ return {
160
+ ok: false,
161
+ status: 403,
162
+ error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`
163
+ };
164
+ }
143
165
  return { ok: false, status: res.status, error: text };
144
166
  }
145
167
  if (!text) return { ok: true, status: res.status, etag };
@@ -287,7 +309,8 @@ function formatResult(res) {
287
309
 
288
310
  // src/tools/operational.ts
289
311
  var HTTPS_PORT_RE = /:443(?:\D|$)/;
290
- function describeServer(raw) {
312
+ function describeServer(rawValue) {
313
+ const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
291
314
  const listen = Array.isArray(raw.listen) ? raw.listen : [];
292
315
  const routes = Array.isArray(raw.routes) ? raw.routes : [];
293
316
  const hasExplicitTls = !!raw.tls_connection_policies;
@@ -409,7 +432,7 @@ ${lines.join("\n")}` }]
409
432
  );
410
433
  server.tool(
411
434
  "caddy_metrics",
412
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
435
+ "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines, keeping only '# HELP'/'# TYPE' lines for matching metrics; the '# EOF' end-of-file marker is always preserved. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
413
436
  {
414
437
  filter: z.string().optional().describe(
415
438
  "Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
@@ -637,13 +660,25 @@ function registerConfigTools(server) {
637
660
  );
638
661
  server.tool(
639
662
  "caddy_load",
640
- "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.",
663
+ "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. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
641
664
  {
642
665
  config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
643
- format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
666
+ format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
667
+ confirm: z3.boolean().optional().default(false).describe("Must be true to replace the running configuration (safety)")
644
668
  },
645
669
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
646
- async ({ config, format }) => {
670
+ async ({ config, format, confirm }) => {
671
+ if (!confirm) {
672
+ return {
673
+ isError: true,
674
+ content: [
675
+ {
676
+ type: "text",
677
+ text: "Refusing to replace the running configuration without confirm=true. caddy_load discards every server and route not present in the supplied config. Re-run with confirm:true to proceed (the prior config is snapshotted and restorable via caddy_revert)."
678
+ }
679
+ ]
680
+ };
681
+ }
647
682
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
648
683
  const current = await configGet();
649
684
  const res = await loadConfig(config, contentType);
@@ -780,6 +815,23 @@ ${lines.join("\n")}` }] };
780
815
 
781
816
  // src/tools/routes.ts
782
817
  import { z as z4 } from "zod";
818
+ var ROUTES_JSON_MAX_CHARS = 2e4;
819
+ var ROUTES_SUMMARY_MAX = 500;
820
+ function serializeRoutesCapped(routes) {
821
+ const parts = [];
822
+ let used = 2;
823
+ for (const route of routes) {
824
+ const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
825
+ const cost = entry.length + (parts.length > 0 ? 2 : 1);
826
+ if (parts.length > 0 && used + cost > ROUTES_JSON_MAX_CHARS) break;
827
+ parts.push(entry);
828
+ used += cost;
829
+ }
830
+ if (parts.length === 0) return { json: "[]", shown: 0 };
831
+ return { json: `[
832
+ ${parts.join(",\n")}
833
+ ]`, shown: parts.length };
834
+ }
783
835
  function safeJoin(value) {
784
836
  if (!Array.isArray(value)) return "";
785
837
  return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
@@ -792,6 +844,7 @@ function stripPort(host) {
792
844
  }
793
845
  const colonIdx = host.lastIndexOf(":");
794
846
  if (colonIdx === -1) return host;
847
+ if (host.indexOf(":") !== colonIdx) return host;
795
848
  const portCandidate = host.substring(colonIdx + 1);
796
849
  if (portCandidate.length === 0 || !/^\d+$/.test(portCandidate)) return host;
797
850
  return host.substring(0, colonIdx);
@@ -845,9 +898,9 @@ function serverNotFoundError(srv, op = "operation") {
845
898
  function registerRouteTools(server) {
846
899
  server.tool(
847
900
  "caddy_reverse_proxy",
848
- "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PUT under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
901
+ "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
849
902
  {
850
- from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
903
+ from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
851
904
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
852
905
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
853
906
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
@@ -856,7 +909,18 @@ function registerRouteTools(server) {
856
909
  },
857
910
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
858
911
  async ({ from, to, server: srv, id }) => {
859
- const match = parseFrom(from);
912
+ const match = parseFrom(from.trim());
913
+ if (match.host?.[0]?.trim() === "" || match.host === void 0 && match.path === void 0) {
914
+ return {
915
+ isError: true,
916
+ content: [
917
+ {
918
+ type: "text",
919
+ text: `Error: "from" value ${JSON.stringify(from)} has no host or path to match on. Supply a domain ('api.local'), a path ('/api/*'), or both ('app.local/ws').`
920
+ }
921
+ ]
922
+ };
923
+ }
860
924
  const cleanedTo = to.map(cleanUpstreamAddr);
861
925
  const route = {
862
926
  match: [match],
@@ -883,7 +947,7 @@ function registerRouteTools(server) {
883
947
  ]
884
948
  };
885
949
  }
886
- const putRes = await configByIdSet(id, route, "PUT");
950
+ const putRes = await configByIdSet(id, route, "PATCH");
887
951
  if (putRes.ok) {
888
952
  return {
889
953
  content: [
@@ -946,7 +1010,7 @@ function registerRouteTools(server) {
946
1010
  );
947
1011
  server.tool(
948
1012
  "caddy_list_routes",
949
- "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
1013
+ "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers, followed by the raw route JSON. Both halves are capped on large servers: the summary at 500 routes, the JSON at 20000 characters (truncated on whole-route boundaries, so it always parses). When either cap trims output, a note says how many routes were omitted -- read the rest with caddy_config_get at 'apps/http/servers/<server>/routes'.",
950
1014
  {
951
1015
  server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
952
1016
  },
@@ -969,7 +1033,8 @@ function registerRouteTools(server) {
969
1033
  };
970
1034
  }
971
1035
  const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
972
- for (let i = 0; i < routes.length; i++) {
1036
+ const summarized = Math.min(routes.length, ROUTES_SUMMARY_MAX);
1037
+ for (let i = 0; i < summarized; i++) {
973
1038
  const rawRoute = routes[i];
974
1039
  if (!rawRoute || typeof rawRoute !== "object") {
975
1040
  lines.push(` Route ${i}: <invalid>`);
@@ -1068,12 +1133,23 @@ function registerRouteTools(server) {
1068
1133
  const terminal = route.terminal === true ? " [terminal]" : "";
1069
1134
  lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
1070
1135
  }
1071
- return {
1072
- content: [
1073
- { type: "text", text: lines.join("\n") },
1074
- { type: "text", text: JSON.stringify(routes, null, 2) }
1075
- ]
1076
- };
1136
+ if (summarized < routes.length) {
1137
+ lines.push(
1138
+ ` ... ${routes.length - summarized} more route(s) not shown (summary caps at ${ROUTES_SUMMARY_MAX}).`
1139
+ );
1140
+ }
1141
+ const { json, shown } = serializeRoutesCapped(routes);
1142
+ const content = [
1143
+ { type: "text", text: lines.join("\n") },
1144
+ { type: "text", text: json }
1145
+ ];
1146
+ if (shown < routes.length) {
1147
+ content.push({
1148
+ type: "text",
1149
+ text: `[JSON block truncated: showing ${shown} of ${routes.length} routes to stay under ${ROUTES_JSON_MAX_CHARS} characters. Read the rest with caddy_config_get at path 'apps/http/servers/${srv}/routes', or one route at a time with caddy_config_by_id.]`
1150
+ });
1151
+ }
1152
+ return { content };
1077
1153
  }
1078
1154
  );
1079
1155
  server.tool(
@@ -1308,8 +1384,11 @@ function registerTlsTools(server) {
1308
1384
  }
1309
1385
 
1310
1386
  // src/server.ts
1311
- var require2 = createRequire(import.meta.url);
1312
- var { version } = require2("../package.json");
1387
+ function resolveVersion() {
1388
+ if (typeof __VERSION__ !== "undefined") return __VERSION__;
1389
+ return createRequire(import.meta.url)("../package.json").version;
1390
+ }
1391
+ var version = resolveVersion();
1313
1392
  function createCaddyServer() {
1314
1393
  const server = new McpServer({ name: "caddy-mcp", version });
1315
1394
  registerConfigTools(server);
@@ -1327,5 +1406,6 @@ async function startServer() {
1327
1406
  }
1328
1407
  export {
1329
1408
  createCaddyServer,
1330
- startServer
1409
+ startServer,
1410
+ version
1331
1411
  };
@@ -0,0 +1,9 @@
1
+ export interface Snapshot {
2
+ config: unknown;
3
+ timestamp: number;
4
+ trigger: string;
5
+ }
6
+ export declare function saveSnapshot(config: unknown, trigger: string): void;
7
+ export declare function listSnapshots(): readonly Snapshot[];
8
+ export declare function getSnapshot(index: number): Snapshot | undefined;
9
+ export declare function clearSnapshots(): void;
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerAdaptTools(server: McpServer): void;
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerConfigTools(server: McpServer): void;
@@ -0,0 +1,19 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /** Default max_lines for caddy_metrics. Prometheus output on busy servers can be megabytes; 500 lines is enough to skim. */
3
+ export declare const METRICS_DEFAULT_MAX_LINES = 500;
4
+ /**
5
+ * Apply the optional substring filter and max_lines truncation to raw Prometheus exposition text.
6
+ *
7
+ * Filter rule: a line is kept if the metric name on that line contains the filter substring.
8
+ * Both `# HELP` / `# TYPE` comment lines and sample lines are matched on their metric name, so any
9
+ * retained metric keeps its descriptive comments alongside its samples. Lines with no parseable
10
+ * metric name (blank lines, free-form `#` comments) are dropped when filtering.
11
+ *
12
+ * Truncation: if the resulting line count exceeds `maxLines`, output is cut at `maxLines` and a
13
+ * trailing `# [truncated, N lines omitted -- use filter to narrow]` comment is appended. If the
14
+ * input contained a `# EOF` end-of-file marker that would have been dropped by the cut, it is
15
+ * re-appended after the truncation comment so strict downstream parsers still see a terminated
16
+ * stream.
17
+ */
18
+ export declare function applyMetricsControls(raw: string, filter: string | undefined, maxLines: number): string;
19
+ export declare function registerOperationalTools(server: McpServer): void;
@@ -0,0 +1,7 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /** Parse a "from" string like "api.example.com" or "example.com/api/*" into match object */
3
+ export declare function parseFrom(from: string): {
4
+ host?: string[];
5
+ path?: string[];
6
+ };
7
+ export declare function registerRouteTools(server: McpServer): void;
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerTlsTools(server: McpServer): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "MCP server for managing Caddy web servers via the admin API",
6
6
  "license": "MIT",
@@ -24,12 +24,13 @@
24
24
  "LICENSE"
25
25
  ],
26
26
  "scripts": {
27
- "build": "tsup",
27
+ "build": "tsup && tsc -p tsconfig.build.json",
28
28
  "dev": "tsup --watch",
29
29
  "test": "vitest run",
30
30
  "lint": "biome check src/",
31
31
  "lint:fix": "biome check --write src/",
32
- "typecheck": "tsc --noEmit",
32
+ "typecheck": "node scripts/typecheck.mjs",
33
+ "typecheck:tsc": "tsc --noEmit",
33
34
  "test:ci": "npm run build && npm test",
34
35
  "prepublishOnly": "npm run build",
35
36
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
@@ -45,13 +46,15 @@
45
46
  "postcss": "^8.5.10",
46
47
  "ip-address": "^10.1.1",
47
48
  "fast-uri": "^3.1.2",
48
- "qs": "^6.15.2"
49
+ "qs": "^6.15.2",
50
+ "esbuild": "^0.28.1"
49
51
  },
50
52
  "devDependencies": {
51
- "@biomejs/biome": "^2.4.11",
52
- "@types/node": "^25.6.0",
53
+ "@biomejs/biome": "~2.4.11",
54
+ "@types/node": "^26.0.0",
55
+ "postject": "^1.0.0-alpha.6",
53
56
  "tsup": "^8.4.0",
54
- "typescript": "^6.0.2",
57
+ "typescript": "^7.0.2",
55
58
  "vitest": "^4.1.4"
56
59
  },
57
60
  "engines": {