@yawlabs/caddy-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,6 @@
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
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/caddy-mcp)](https://github.com/YawLabs/caddy-mcp/stargazers)
6
- [![CI](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
7
6
 
8
7
  **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 18 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics, snapshots.
9
8
 
package/dist/index.js CHANGED
@@ -41,6 +41,16 @@ function getHeaders(contentType) {
41
41
  function normalizePath(path) {
42
42
  return path.replace(/^\/?(config(\/|$))?/, "");
43
43
  }
44
+ function rejectTraversal(path) {
45
+ if (/(^|\/)\.\.(\/|$)/.test(path)) {
46
+ return {
47
+ ok: false,
48
+ status: 0,
49
+ error: `Invalid path "${path}": '..' segments are not allowed`
50
+ };
51
+ }
52
+ return null;
53
+ }
44
54
  function sleep(ms) {
45
55
  return new Promise((resolve) => setTimeout(resolve, ms));
46
56
  }
@@ -87,7 +97,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
87
97
  setEtag(path, etag);
88
98
  }
89
99
  if (isWrite && res.ok && isConfigPath) {
90
- etagCache.delete(path);
100
+ if (method === "PATCH" || method === "PUT") {
101
+ if (etag) {
102
+ setEtag(path, etag);
103
+ } else {
104
+ etagCache.delete(path);
105
+ }
106
+ } else {
107
+ etagCache.delete(path);
108
+ }
91
109
  }
92
110
  if (!res.ok) {
93
111
  if (res.status === 412) {
@@ -129,22 +147,32 @@ async function attemptRequest(method, path, body, contentType, timeout) {
129
147
  }
130
148
  function configGet(path = "") {
131
149
  const normalized = normalizePath(path);
150
+ const bad = rejectTraversal(normalized);
151
+ if (bad) return Promise.resolve(bad);
132
152
  return caddyRequest("GET", `/config/${normalized}`);
133
153
  }
134
154
  function configPost(path, value) {
135
155
  const normalized = normalizePath(path);
156
+ const bad = rejectTraversal(normalized);
157
+ if (bad) return Promise.resolve(bad);
136
158
  return caddyRequest("POST", `/config/${normalized}`, value);
137
159
  }
138
160
  function configPut(path, value) {
139
161
  const normalized = normalizePath(path);
162
+ const bad = rejectTraversal(normalized);
163
+ if (bad) return Promise.resolve(bad);
140
164
  return caddyRequest("PUT", `/config/${normalized}`, value);
141
165
  }
142
166
  function configPatch(path, value) {
143
167
  const normalized = normalizePath(path);
168
+ const bad = rejectTraversal(normalized);
169
+ if (bad) return Promise.resolve(bad);
144
170
  return caddyRequest("PATCH", `/config/${normalized}`, value);
145
171
  }
146
172
  function configDelete(path) {
147
173
  const normalized = normalizePath(path);
174
+ const bad = rejectTraversal(normalized);
175
+ if (bad) return Promise.resolve(bad);
148
176
  return caddyRequest("DELETE", `/config/${normalized}`);
149
177
  }
150
178
  var LOAD_TIMEOUT = 6e4;
@@ -163,20 +191,36 @@ function getUpstreams() {
163
191
  return caddyRequest("GET", "/reverse_proxy/upstreams");
164
192
  }
165
193
  function getPki(ca = "local") {
194
+ const bad = rejectTraversal(ca);
195
+ if (bad) return Promise.resolve(bad);
166
196
  return caddyRequest("GET", `/pki/ca/${ca}`);
167
197
  }
168
198
  function getPkiCertificates(ca = "local") {
199
+ const bad = rejectTraversal(ca);
200
+ if (bad) return Promise.resolve(bad);
169
201
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
170
202
  }
171
203
  function configByIdGet(id, subpath = "") {
204
+ const badId = rejectTraversal(id);
205
+ if (badId) return Promise.resolve(badId);
206
+ const bad = rejectTraversal(subpath);
207
+ if (bad) return Promise.resolve(bad);
172
208
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
173
209
  return caddyRequest("GET", path);
174
210
  }
175
211
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
212
+ const badId = rejectTraversal(id);
213
+ if (badId) return Promise.resolve(badId);
214
+ const bad = rejectTraversal(subpath);
215
+ if (bad) return Promise.resolve(bad);
176
216
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
177
217
  return caddyRequest(method, path, value);
178
218
  }
179
219
  function configByIdDelete(id, subpath = "") {
220
+ const badId = rejectTraversal(id);
221
+ if (badId) return Promise.resolve(badId);
222
+ const bad = rejectTraversal(subpath);
223
+ if (bad) return Promise.resolve(bad);
180
224
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
181
225
  return caddyRequest("DELETE", path);
182
226
  }
@@ -399,6 +443,12 @@ ${lines.join("\n")}` }] };
399
443
  if (action === "save") {
400
444
  const current2 = await configGet();
401
445
  if (!current2.ok) return formatResult(current2);
446
+ if (current2.data === void 0) {
447
+ return {
448
+ isError: true,
449
+ content: [{ type: "text", text: "Error: no config loaded to snapshot" }]
450
+ };
451
+ }
402
452
  saveSnapshot(current2.data, "manual");
403
453
  return { content: [{ type: "text", text: "Snapshot saved." }] };
404
454
  }
@@ -485,6 +535,35 @@ function describeServer(raw) {
485
535
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
486
536
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
487
537
  }
538
+ var METRICS_DEFAULT_MAX_LINES = 500;
539
+ function metricNameFromLine(line) {
540
+ const trimmed = line.trimStart();
541
+ if (trimmed === "") return void 0;
542
+ if (trimmed.startsWith("#")) {
543
+ const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
544
+ return m2 ? m2[1] : void 0;
545
+ }
546
+ const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
547
+ return m ? m[1] : void 0;
548
+ }
549
+ function applyMetricsControls(raw, filter, maxLines) {
550
+ const lines = raw.split("\n");
551
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
552
+ let filtered;
553
+ if (filter && filter.length > 0) {
554
+ filtered = lines.filter((line) => {
555
+ const name = metricNameFromLine(line);
556
+ return name?.includes(filter) ?? false;
557
+ });
558
+ } else {
559
+ filtered = lines;
560
+ }
561
+ if (filtered.length <= maxLines) return filtered.join("\n");
562
+ const dropped = filtered.length - maxLines;
563
+ const kept = filtered.slice(0, maxLines);
564
+ kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
565
+ return kept.join("\n");
566
+ }
488
567
  function findAcmeEmail(policies) {
489
568
  if (!Array.isArray(policies)) return void 0;
490
569
  for (const rawPolicy of policies) {
@@ -567,10 +646,22 @@ ${lines.join("\n")}` }]
567
646
  );
568
647
  server.tool(
569
648
  "caddy_metrics",
570
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
571
- {},
649
+ "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.",
650
+ {
651
+ filter: z3.string().optional().describe(
652
+ "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."
653
+ ),
654
+ max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
655
+ },
572
656
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
573
- async () => formatResult(await getMetrics())
657
+ async ({ filter, max_lines }) => {
658
+ const res = await getMetrics();
659
+ if (!res.ok) return formatResult(res);
660
+ const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
661
+ const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
662
+ const text = applyMetricsControls(raw, filter, limit);
663
+ return { content: [{ type: "text", text: text || "OK" }] };
664
+ }
574
665
  );
575
666
  server.tool(
576
667
  "caddy_stop",
@@ -601,7 +692,10 @@ function parseFrom(from) {
601
692
  const slashIdx = cleaned.indexOf("/");
602
693
  if (slashIdx > 0) {
603
694
  match.host = [cleaned.substring(0, slashIdx)];
604
- match.path = [cleaned.substring(slashIdx)];
695
+ const path = cleaned.substring(slashIdx);
696
+ if (path !== "/") {
697
+ match.path = [path];
698
+ }
605
699
  } else if (cleaned.startsWith("/")) {
606
700
  match.path = [cleaned];
607
701
  } else {
@@ -612,6 +706,21 @@ function parseFrom(from) {
612
706
  function cleanUpstreamAddr(addr) {
613
707
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
614
708
  }
709
+ function isParentMissing(res) {
710
+ if (res.ok) return false;
711
+ if (res.status === 404) return true;
712
+ return res.error?.includes("key does not exist") ?? false;
713
+ }
714
+ function isUnknownId(res) {
715
+ if (res.ok) return false;
716
+ if (res.status === 404) return true;
717
+ const body = (res.error ?? "").toLowerCase();
718
+ return body.includes("unknown object id") || body.includes("no id found");
719
+ }
720
+ function isRouteShape(obj) {
721
+ if (!obj || typeof obj !== "object") return false;
722
+ return Array.isArray(obj.handle);
723
+ }
615
724
  function serverNotFoundError(srv) {
616
725
  return {
617
726
  isError: true,
@@ -626,14 +735,17 @@ function serverNotFoundError(srv) {
626
735
  function registerRouteTools(server) {
627
736
  server.tool(
628
737
  "caddy_reverse_proxy",
629
- "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'].",
738
+ "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.",
630
739
  {
631
740
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
632
741
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
633
- server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
742
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
743
+ id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
744
+ "Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
745
+ )
634
746
  },
635
747
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
636
- async ({ from, to, server: srv }) => {
748
+ async ({ from, to, server: srv, id }) => {
637
749
  const match = parseFrom(from);
638
750
  const cleanedTo = to.map(cleanUpstreamAddr);
639
751
  const route = {
@@ -646,11 +758,58 @@ function registerRouteTools(server) {
646
758
  ],
647
759
  terminal: true
648
760
  };
761
+ if (id) {
762
+ route["@id"] = id;
763
+ const existing = await configByIdGet(id);
764
+ if (existing.ok) {
765
+ if (!isRouteShape(existing.data)) {
766
+ return {
767
+ isError: true,
768
+ content: [
769
+ {
770
+ type: "text",
771
+ text: `Error: @id "${id}" is already in use by a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- pick a different id, or remove the existing object first with caddy_config_by_id { id: "${id}", action: "delete" }.`
772
+ }
773
+ ]
774
+ };
775
+ }
776
+ const putRes = await configByIdSet(id, route, "PUT");
777
+ if (putRes.ok) {
778
+ return {
779
+ content: [
780
+ {
781
+ type: "text",
782
+ text: `Route set @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
783
+ }
784
+ ]
785
+ };
786
+ }
787
+ return formatResult(putRes);
788
+ }
789
+ if (!isUnknownId(existing)) {
790
+ return formatResult(existing);
791
+ }
792
+ const postRes = await configPost(`apps/http/servers/${srv}/routes`, route);
793
+ if (postRes.ok) {
794
+ return {
795
+ content: [
796
+ {
797
+ type: "text",
798
+ text: `Route created @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
799
+ }
800
+ ]
801
+ };
802
+ }
803
+ if (isParentMissing(postRes)) {
804
+ return serverNotFoundError(srv);
805
+ }
806
+ return formatResult(postRes);
807
+ }
649
808
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
650
809
  if (res.ok) {
651
810
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
652
811
  }
653
- if (!res.ok && res.error?.includes("key does not exist")) {
812
+ if (isParentMissing(res)) {
654
813
  return serverNotFoundError(srv);
655
814
  }
656
815
  return formatResult(res);
@@ -669,7 +828,7 @@ function registerRouteTools(server) {
669
828
  async ({ match, handle, server: srv, terminal }) => {
670
829
  const route = { match, handle, terminal };
671
830
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
672
- if (!res.ok && res.error?.includes("key does not exist")) {
831
+ if (isParentMissing(res)) {
673
832
  return serverNotFoundError(srv);
674
833
  }
675
834
  return formatResult(res);
@@ -809,7 +968,7 @@ function registerRouteTools(server) {
809
968
  );
810
969
  server.tool(
811
970
  "caddy_remove_route",
812
- "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
971
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
813
972
  {
814
973
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
815
974
  index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
@@ -882,9 +1041,9 @@ function buildTlsConfig(fields) {
882
1041
  }
883
1042
  };
884
1043
  }
885
- function bothErrors(label, patchRes, postRes) {
1044
+ function bothErrors(label, patchRes, writeRes, writeLabel) {
886
1045
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
887
- const postErr = postRes.error || `HTTP ${postRes.status}`;
1046
+ const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
888
1047
  return {
889
1048
  isError: true,
890
1049
  content: [
@@ -892,11 +1051,97 @@ function bothErrors(label, patchRes, postRes) {
892
1051
  type: "text",
893
1052
  text: `Error: Failed to set ${label}.
894
1053
  PATCH attempt: ${patchErr}
895
- POST fallback: ${postErr}`
1054
+ ${writeLabel} fallback: ${writeErr}`
896
1055
  }
897
1056
  ]
898
1057
  };
899
1058
  }
1059
+ function isPlainObject(v) {
1060
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1061
+ }
1062
+ function deepClone(v) {
1063
+ return JSON.parse(JSON.stringify(v));
1064
+ }
1065
+ function mergeIssuerFields(existing, fields) {
1066
+ const merged = deepClone(existing);
1067
+ const automation = merged.automation;
1068
+ const issuer = automation.policies[0].issuers[0];
1069
+ if (fields.email !== void 0) issuer.email = fields.email;
1070
+ if (fields.ca !== void 0) issuer.ca = fields.ca;
1071
+ return merged;
1072
+ }
1073
+ function validateIssuerShape(tls) {
1074
+ const automation = tls.automation;
1075
+ if (!isPlainObject(automation)) {
1076
+ return "apps/tls.automation is missing or not an object";
1077
+ }
1078
+ const policies = automation.policies;
1079
+ if (!Array.isArray(policies) || policies.length === 0) {
1080
+ return "apps/tls.automation.policies is missing, not an array, or empty";
1081
+ }
1082
+ const policy0 = policies[0];
1083
+ if (!isPlainObject(policy0)) {
1084
+ return "apps/tls.automation.policies[0] is not an object";
1085
+ }
1086
+ const issuers = policy0.issuers;
1087
+ if (!Array.isArray(issuers) || issuers.length === 0) {
1088
+ return "apps/tls.automation.policies[0].issuers is missing, not an array, or empty";
1089
+ }
1090
+ const issuer0 = issuers[0];
1091
+ if (!isPlainObject(issuer0)) {
1092
+ return "apps/tls.automation.policies[0].issuers[0] is not an object";
1093
+ }
1094
+ return null;
1095
+ }
1096
+ async function safeFallback(label, patchRes, fields) {
1097
+ const getRes = await configGet("apps/tls");
1098
+ const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1099
+ if (absent) {
1100
+ const postRes = await configPost("apps/tls", buildTlsConfig(fields));
1101
+ if (postRes.ok) return { kind: "ok" };
1102
+ return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
1103
+ }
1104
+ if (!getRes.ok) {
1105
+ return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
1106
+ }
1107
+ if (!isPlainObject(getRes.data)) {
1108
+ return {
1109
+ kind: "tool-error",
1110
+ result: {
1111
+ isError: true,
1112
+ content: [
1113
+ {
1114
+ type: "text",
1115
+ text: `Error: Failed to set ${label}.
1116
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1117
+ Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1118
+ }
1119
+ ]
1120
+ }
1121
+ };
1122
+ }
1123
+ const shapeError = validateIssuerShape(getRes.data);
1124
+ if (shapeError) {
1125
+ return {
1126
+ kind: "tool-error",
1127
+ result: {
1128
+ isError: true,
1129
+ content: [
1130
+ {
1131
+ type: "text",
1132
+ text: `Error: Failed to set ${label}.
1133
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1134
+ Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
1135
+ }
1136
+ ]
1137
+ }
1138
+ };
1139
+ }
1140
+ const merged = mergeIssuerFields(getRes.data, fields);
1141
+ const putRes = await configPut("apps/tls", merged);
1142
+ if (putRes.ok) return { kind: "ok" };
1143
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1144
+ }
900
1145
  function registerTlsTools(server) {
901
1146
  server.tool(
902
1147
  "caddy_tls",
@@ -919,9 +1164,9 @@ function registerTlsTools(server) {
919
1164
  };
920
1165
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
921
1166
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
922
- const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
923
- if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
924
- return bothErrors("ACME email", patchRes, postRes);
1167
+ const outcome = await safeFallback("ACME email", patchRes, { email });
1168
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1169
+ return outcome.result;
925
1170
  }
926
1171
  if (action === "set_acme_ca") {
927
1172
  if (!ca)
@@ -931,9 +1176,9 @@ function registerTlsTools(server) {
931
1176
  };
932
1177
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
933
1178
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
934
- const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
935
- if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
936
- return bothErrors("ACME CA", patchRes, postRes);
1179
+ const outcome = await safeFallback("ACME CA", patchRes, { ca });
1180
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1181
+ return outcome.result;
937
1182
  }
938
1183
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
939
1184
  }
package/dist/server.js CHANGED
@@ -39,6 +39,16 @@ function getHeaders(contentType) {
39
39
  function normalizePath(path) {
40
40
  return path.replace(/^\/?(config(\/|$))?/, "");
41
41
  }
42
+ function rejectTraversal(path) {
43
+ if (/(^|\/)\.\.(\/|$)/.test(path)) {
44
+ return {
45
+ ok: false,
46
+ status: 0,
47
+ error: `Invalid path "${path}": '..' segments are not allowed`
48
+ };
49
+ }
50
+ return null;
51
+ }
42
52
  function sleep(ms) {
43
53
  return new Promise((resolve) => setTimeout(resolve, ms));
44
54
  }
@@ -85,7 +95,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
85
95
  setEtag(path, etag);
86
96
  }
87
97
  if (isWrite && res.ok && isConfigPath) {
88
- etagCache.delete(path);
98
+ if (method === "PATCH" || method === "PUT") {
99
+ if (etag) {
100
+ setEtag(path, etag);
101
+ } else {
102
+ etagCache.delete(path);
103
+ }
104
+ } else {
105
+ etagCache.delete(path);
106
+ }
89
107
  }
90
108
  if (!res.ok) {
91
109
  if (res.status === 412) {
@@ -127,22 +145,32 @@ async function attemptRequest(method, path, body, contentType, timeout) {
127
145
  }
128
146
  function configGet(path = "") {
129
147
  const normalized = normalizePath(path);
148
+ const bad = rejectTraversal(normalized);
149
+ if (bad) return Promise.resolve(bad);
130
150
  return caddyRequest("GET", `/config/${normalized}`);
131
151
  }
132
152
  function configPost(path, value) {
133
153
  const normalized = normalizePath(path);
154
+ const bad = rejectTraversal(normalized);
155
+ if (bad) return Promise.resolve(bad);
134
156
  return caddyRequest("POST", `/config/${normalized}`, value);
135
157
  }
136
158
  function configPut(path, value) {
137
159
  const normalized = normalizePath(path);
160
+ const bad = rejectTraversal(normalized);
161
+ if (bad) return Promise.resolve(bad);
138
162
  return caddyRequest("PUT", `/config/${normalized}`, value);
139
163
  }
140
164
  function configPatch(path, value) {
141
165
  const normalized = normalizePath(path);
166
+ const bad = rejectTraversal(normalized);
167
+ if (bad) return Promise.resolve(bad);
142
168
  return caddyRequest("PATCH", `/config/${normalized}`, value);
143
169
  }
144
170
  function configDelete(path) {
145
171
  const normalized = normalizePath(path);
172
+ const bad = rejectTraversal(normalized);
173
+ if (bad) return Promise.resolve(bad);
146
174
  return caddyRequest("DELETE", `/config/${normalized}`);
147
175
  }
148
176
  var LOAD_TIMEOUT = 6e4;
@@ -161,20 +189,36 @@ function getUpstreams() {
161
189
  return caddyRequest("GET", "/reverse_proxy/upstreams");
162
190
  }
163
191
  function getPki(ca = "local") {
192
+ const bad = rejectTraversal(ca);
193
+ if (bad) return Promise.resolve(bad);
164
194
  return caddyRequest("GET", `/pki/ca/${ca}`);
165
195
  }
166
196
  function getPkiCertificates(ca = "local") {
197
+ const bad = rejectTraversal(ca);
198
+ if (bad) return Promise.resolve(bad);
167
199
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
168
200
  }
169
201
  function configByIdGet(id, subpath = "") {
202
+ const badId = rejectTraversal(id);
203
+ if (badId) return Promise.resolve(badId);
204
+ const bad = rejectTraversal(subpath);
205
+ if (bad) return Promise.resolve(bad);
170
206
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
171
207
  return caddyRequest("GET", path);
172
208
  }
173
209
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
210
+ const badId = rejectTraversal(id);
211
+ if (badId) return Promise.resolve(badId);
212
+ const bad = rejectTraversal(subpath);
213
+ if (bad) return Promise.resolve(bad);
174
214
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
175
215
  return caddyRequest(method, path, value);
176
216
  }
177
217
  function configByIdDelete(id, subpath = "") {
218
+ const badId = rejectTraversal(id);
219
+ if (badId) return Promise.resolve(badId);
220
+ const bad = rejectTraversal(subpath);
221
+ if (bad) return Promise.resolve(bad);
178
222
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
179
223
  return caddyRequest("DELETE", path);
180
224
  }
@@ -397,6 +441,12 @@ ${lines.join("\n")}` }] };
397
441
  if (action === "save") {
398
442
  const current2 = await configGet();
399
443
  if (!current2.ok) return formatResult(current2);
444
+ if (current2.data === void 0) {
445
+ return {
446
+ isError: true,
447
+ content: [{ type: "text", text: "Error: no config loaded to snapshot" }]
448
+ };
449
+ }
400
450
  saveSnapshot(current2.data, "manual");
401
451
  return { content: [{ type: "text", text: "Snapshot saved." }] };
402
452
  }
@@ -483,6 +533,35 @@ function describeServer(raw) {
483
533
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
484
534
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
485
535
  }
536
+ var METRICS_DEFAULT_MAX_LINES = 500;
537
+ function metricNameFromLine(line) {
538
+ const trimmed = line.trimStart();
539
+ if (trimmed === "") return void 0;
540
+ if (trimmed.startsWith("#")) {
541
+ const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
542
+ return m2 ? m2[1] : void 0;
543
+ }
544
+ const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
545
+ return m ? m[1] : void 0;
546
+ }
547
+ function applyMetricsControls(raw, filter, maxLines) {
548
+ const lines = raw.split("\n");
549
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
550
+ let filtered;
551
+ if (filter && filter.length > 0) {
552
+ filtered = lines.filter((line) => {
553
+ const name = metricNameFromLine(line);
554
+ return name?.includes(filter) ?? false;
555
+ });
556
+ } else {
557
+ filtered = lines;
558
+ }
559
+ if (filtered.length <= maxLines) return filtered.join("\n");
560
+ const dropped = filtered.length - maxLines;
561
+ const kept = filtered.slice(0, maxLines);
562
+ kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
563
+ return kept.join("\n");
564
+ }
486
565
  function findAcmeEmail(policies) {
487
566
  if (!Array.isArray(policies)) return void 0;
488
567
  for (const rawPolicy of policies) {
@@ -565,10 +644,22 @@ ${lines.join("\n")}` }]
565
644
  );
566
645
  server.tool(
567
646
  "caddy_metrics",
568
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
569
- {},
647
+ "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.",
648
+ {
649
+ filter: z3.string().optional().describe(
650
+ "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."
651
+ ),
652
+ max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
653
+ },
570
654
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
571
- async () => formatResult(await getMetrics())
655
+ async ({ filter, max_lines }) => {
656
+ const res = await getMetrics();
657
+ if (!res.ok) return formatResult(res);
658
+ const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
659
+ const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
660
+ const text = applyMetricsControls(raw, filter, limit);
661
+ return { content: [{ type: "text", text: text || "OK" }] };
662
+ }
572
663
  );
573
664
  server.tool(
574
665
  "caddy_stop",
@@ -599,7 +690,10 @@ function parseFrom(from) {
599
690
  const slashIdx = cleaned.indexOf("/");
600
691
  if (slashIdx > 0) {
601
692
  match.host = [cleaned.substring(0, slashIdx)];
602
- match.path = [cleaned.substring(slashIdx)];
693
+ const path = cleaned.substring(slashIdx);
694
+ if (path !== "/") {
695
+ match.path = [path];
696
+ }
603
697
  } else if (cleaned.startsWith("/")) {
604
698
  match.path = [cleaned];
605
699
  } else {
@@ -610,6 +704,21 @@ function parseFrom(from) {
610
704
  function cleanUpstreamAddr(addr) {
611
705
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
612
706
  }
707
+ function isParentMissing(res) {
708
+ if (res.ok) return false;
709
+ if (res.status === 404) return true;
710
+ return res.error?.includes("key does not exist") ?? false;
711
+ }
712
+ function isUnknownId(res) {
713
+ if (res.ok) return false;
714
+ if (res.status === 404) return true;
715
+ const body = (res.error ?? "").toLowerCase();
716
+ return body.includes("unknown object id") || body.includes("no id found");
717
+ }
718
+ function isRouteShape(obj) {
719
+ if (!obj || typeof obj !== "object") return false;
720
+ return Array.isArray(obj.handle);
721
+ }
613
722
  function serverNotFoundError(srv) {
614
723
  return {
615
724
  isError: true,
@@ -624,14 +733,17 @@ function serverNotFoundError(srv) {
624
733
  function registerRouteTools(server) {
625
734
  server.tool(
626
735
  "caddy_reverse_proxy",
627
- "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'].",
736
+ "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.",
628
737
  {
629
738
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
630
739
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
631
- server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
740
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
741
+ id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
742
+ "Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
743
+ )
632
744
  },
633
745
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
634
- async ({ from, to, server: srv }) => {
746
+ async ({ from, to, server: srv, id }) => {
635
747
  const match = parseFrom(from);
636
748
  const cleanedTo = to.map(cleanUpstreamAddr);
637
749
  const route = {
@@ -644,11 +756,58 @@ function registerRouteTools(server) {
644
756
  ],
645
757
  terminal: true
646
758
  };
759
+ if (id) {
760
+ route["@id"] = id;
761
+ const existing = await configByIdGet(id);
762
+ if (existing.ok) {
763
+ if (!isRouteShape(existing.data)) {
764
+ return {
765
+ isError: true,
766
+ content: [
767
+ {
768
+ type: "text",
769
+ text: `Error: @id "${id}" is already in use by a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- pick a different id, or remove the existing object first with caddy_config_by_id { id: "${id}", action: "delete" }.`
770
+ }
771
+ ]
772
+ };
773
+ }
774
+ const putRes = await configByIdSet(id, route, "PUT");
775
+ if (putRes.ok) {
776
+ return {
777
+ content: [
778
+ {
779
+ type: "text",
780
+ text: `Route set @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
781
+ }
782
+ ]
783
+ };
784
+ }
785
+ return formatResult(putRes);
786
+ }
787
+ if (!isUnknownId(existing)) {
788
+ return formatResult(existing);
789
+ }
790
+ const postRes = await configPost(`apps/http/servers/${srv}/routes`, route);
791
+ if (postRes.ok) {
792
+ return {
793
+ content: [
794
+ {
795
+ type: "text",
796
+ text: `Route created @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
797
+ }
798
+ ]
799
+ };
800
+ }
801
+ if (isParentMissing(postRes)) {
802
+ return serverNotFoundError(srv);
803
+ }
804
+ return formatResult(postRes);
805
+ }
647
806
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
648
807
  if (res.ok) {
649
808
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
650
809
  }
651
- if (!res.ok && res.error?.includes("key does not exist")) {
810
+ if (isParentMissing(res)) {
652
811
  return serverNotFoundError(srv);
653
812
  }
654
813
  return formatResult(res);
@@ -667,7 +826,7 @@ function registerRouteTools(server) {
667
826
  async ({ match, handle, server: srv, terminal }) => {
668
827
  const route = { match, handle, terminal };
669
828
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
670
- if (!res.ok && res.error?.includes("key does not exist")) {
829
+ if (isParentMissing(res)) {
671
830
  return serverNotFoundError(srv);
672
831
  }
673
832
  return formatResult(res);
@@ -807,7 +966,7 @@ function registerRouteTools(server) {
807
966
  );
808
967
  server.tool(
809
968
  "caddy_remove_route",
810
- "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
969
+ "Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
811
970
  {
812
971
  id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
813
972
  index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
@@ -880,9 +1039,9 @@ function buildTlsConfig(fields) {
880
1039
  }
881
1040
  };
882
1041
  }
883
- function bothErrors(label, patchRes, postRes) {
1042
+ function bothErrors(label, patchRes, writeRes, writeLabel) {
884
1043
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
885
- const postErr = postRes.error || `HTTP ${postRes.status}`;
1044
+ const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
886
1045
  return {
887
1046
  isError: true,
888
1047
  content: [
@@ -890,11 +1049,97 @@ function bothErrors(label, patchRes, postRes) {
890
1049
  type: "text",
891
1050
  text: `Error: Failed to set ${label}.
892
1051
  PATCH attempt: ${patchErr}
893
- POST fallback: ${postErr}`
1052
+ ${writeLabel} fallback: ${writeErr}`
894
1053
  }
895
1054
  ]
896
1055
  };
897
1056
  }
1057
+ function isPlainObject(v) {
1058
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1059
+ }
1060
+ function deepClone(v) {
1061
+ return JSON.parse(JSON.stringify(v));
1062
+ }
1063
+ function mergeIssuerFields(existing, fields) {
1064
+ const merged = deepClone(existing);
1065
+ const automation = merged.automation;
1066
+ const issuer = automation.policies[0].issuers[0];
1067
+ if (fields.email !== void 0) issuer.email = fields.email;
1068
+ if (fields.ca !== void 0) issuer.ca = fields.ca;
1069
+ return merged;
1070
+ }
1071
+ function validateIssuerShape(tls) {
1072
+ const automation = tls.automation;
1073
+ if (!isPlainObject(automation)) {
1074
+ return "apps/tls.automation is missing or not an object";
1075
+ }
1076
+ const policies = automation.policies;
1077
+ if (!Array.isArray(policies) || policies.length === 0) {
1078
+ return "apps/tls.automation.policies is missing, not an array, or empty";
1079
+ }
1080
+ const policy0 = policies[0];
1081
+ if (!isPlainObject(policy0)) {
1082
+ return "apps/tls.automation.policies[0] is not an object";
1083
+ }
1084
+ const issuers = policy0.issuers;
1085
+ if (!Array.isArray(issuers) || issuers.length === 0) {
1086
+ return "apps/tls.automation.policies[0].issuers is missing, not an array, or empty";
1087
+ }
1088
+ const issuer0 = issuers[0];
1089
+ if (!isPlainObject(issuer0)) {
1090
+ return "apps/tls.automation.policies[0].issuers[0] is not an object";
1091
+ }
1092
+ return null;
1093
+ }
1094
+ async function safeFallback(label, patchRes, fields) {
1095
+ const getRes = await configGet("apps/tls");
1096
+ const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1097
+ if (absent) {
1098
+ const postRes = await configPost("apps/tls", buildTlsConfig(fields));
1099
+ if (postRes.ok) return { kind: "ok" };
1100
+ return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
1101
+ }
1102
+ if (!getRes.ok) {
1103
+ return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
1104
+ }
1105
+ if (!isPlainObject(getRes.data)) {
1106
+ return {
1107
+ kind: "tool-error",
1108
+ result: {
1109
+ isError: true,
1110
+ content: [
1111
+ {
1112
+ type: "text",
1113
+ text: `Error: Failed to set ${label}.
1114
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1115
+ Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1116
+ }
1117
+ ]
1118
+ }
1119
+ };
1120
+ }
1121
+ const shapeError = validateIssuerShape(getRes.data);
1122
+ if (shapeError) {
1123
+ return {
1124
+ kind: "tool-error",
1125
+ result: {
1126
+ isError: true,
1127
+ content: [
1128
+ {
1129
+ type: "text",
1130
+ text: `Error: Failed to set ${label}.
1131
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1132
+ Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
1133
+ }
1134
+ ]
1135
+ }
1136
+ };
1137
+ }
1138
+ const merged = mergeIssuerFields(getRes.data, fields);
1139
+ const putRes = await configPut("apps/tls", merged);
1140
+ if (putRes.ok) return { kind: "ok" };
1141
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1142
+ }
898
1143
  function registerTlsTools(server) {
899
1144
  server.tool(
900
1145
  "caddy_tls",
@@ -917,9 +1162,9 @@ function registerTlsTools(server) {
917
1162
  };
918
1163
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
919
1164
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
920
- const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
921
- if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
922
- return bothErrors("ACME email", patchRes, postRes);
1165
+ const outcome = await safeFallback("ACME email", patchRes, { email });
1166
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1167
+ return outcome.result;
923
1168
  }
924
1169
  if (action === "set_acme_ca") {
925
1170
  if (!ca)
@@ -929,9 +1174,9 @@ function registerTlsTools(server) {
929
1174
  };
930
1175
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
931
1176
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
932
- const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
933
- if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
934
- return bothErrors("ACME CA", patchRes, postRes);
1177
+ const outcome = await safeFallback("ACME CA", patchRes, { ca });
1178
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1179
+ return outcome.result;
935
1180
  }
936
1181
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
937
1182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for managing Caddy web servers via the admin API",
5
5
  "license": "MIT",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "overrides": {
42
42
  "hono": "^4.12.14",
43
- "@hono/node-server": "^1.19.13"
43
+ "@hono/node-server": "^1.19.13",
44
+ "postcss": "^8.5.10"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@biomejs/biome": "^2.4.11",