@yawlabs/caddy-mcp 1.0.1 → 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
@@ -97,7 +97,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
97
97
  setEtag(path, etag);
98
98
  }
99
99
  if (isWrite && res.ok && isConfigPath) {
100
- 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
+ }
101
109
  }
102
110
  if (!res.ok) {
103
111
  if (res.status === 412) {
@@ -183,24 +191,34 @@ function getUpstreams() {
183
191
  return caddyRequest("GET", "/reverse_proxy/upstreams");
184
192
  }
185
193
  function getPki(ca = "local") {
194
+ const bad = rejectTraversal(ca);
195
+ if (bad) return Promise.resolve(bad);
186
196
  return caddyRequest("GET", `/pki/ca/${ca}`);
187
197
  }
188
198
  function getPkiCertificates(ca = "local") {
199
+ const bad = rejectTraversal(ca);
200
+ if (bad) return Promise.resolve(bad);
189
201
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
190
202
  }
191
203
  function configByIdGet(id, subpath = "") {
204
+ const badId = rejectTraversal(id);
205
+ if (badId) return Promise.resolve(badId);
192
206
  const bad = rejectTraversal(subpath);
193
207
  if (bad) return Promise.resolve(bad);
194
208
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
195
209
  return caddyRequest("GET", path);
196
210
  }
197
211
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
212
+ const badId = rejectTraversal(id);
213
+ if (badId) return Promise.resolve(badId);
198
214
  const bad = rejectTraversal(subpath);
199
215
  if (bad) return Promise.resolve(bad);
200
216
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
201
217
  return caddyRequest(method, path, value);
202
218
  }
203
219
  function configByIdDelete(id, subpath = "") {
220
+ const badId = rejectTraversal(id);
221
+ if (badId) return Promise.resolve(badId);
204
222
  const bad = rejectTraversal(subpath);
205
223
  if (bad) return Promise.resolve(bad);
206
224
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
@@ -517,6 +535,35 @@ function describeServer(raw) {
517
535
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
518
536
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
519
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
+ }
520
567
  function findAcmeEmail(policies) {
521
568
  if (!Array.isArray(policies)) return void 0;
522
569
  for (const rawPolicy of policies) {
@@ -599,10 +646,22 @@ ${lines.join("\n")}` }]
599
646
  );
600
647
  server.tool(
601
648
  "caddy_metrics",
602
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
603
- {},
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
+ },
604
656
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
605
- 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
+ }
606
665
  );
607
666
  server.tool(
608
667
  "caddy_stop",
@@ -633,7 +692,10 @@ function parseFrom(from) {
633
692
  const slashIdx = cleaned.indexOf("/");
634
693
  if (slashIdx > 0) {
635
694
  match.host = [cleaned.substring(0, slashIdx)];
636
- match.path = [cleaned.substring(slashIdx)];
695
+ const path = cleaned.substring(slashIdx);
696
+ if (path !== "/") {
697
+ match.path = [path];
698
+ }
637
699
  } else if (cleaned.startsWith("/")) {
638
700
  match.path = [cleaned];
639
701
  } else {
@@ -644,6 +706,21 @@ function parseFrom(from) {
644
706
  function cleanUpstreamAddr(addr) {
645
707
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
646
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
+ }
647
724
  function serverNotFoundError(srv) {
648
725
  return {
649
726
  isError: true,
@@ -658,14 +735,17 @@ function serverNotFoundError(srv) {
658
735
  function registerRouteTools(server) {
659
736
  server.tool(
660
737
  "caddy_reverse_proxy",
661
- "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.",
662
739
  {
663
740
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
664
741
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
665
- 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
+ )
666
746
  },
667
747
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
668
- async ({ from, to, server: srv }) => {
748
+ async ({ from, to, server: srv, id }) => {
669
749
  const match = parseFrom(from);
670
750
  const cleanedTo = to.map(cleanUpstreamAddr);
671
751
  const route = {
@@ -678,11 +758,58 @@ function registerRouteTools(server) {
678
758
  ],
679
759
  terminal: true
680
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
+ }
681
808
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
682
809
  if (res.ok) {
683
810
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
684
811
  }
685
- if (!res.ok && res.error?.includes("key does not exist")) {
812
+ if (isParentMissing(res)) {
686
813
  return serverNotFoundError(srv);
687
814
  }
688
815
  return formatResult(res);
@@ -701,7 +828,7 @@ function registerRouteTools(server) {
701
828
  async ({ match, handle, server: srv, terminal }) => {
702
829
  const route = { match, handle, terminal };
703
830
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
704
- if (!res.ok && res.error?.includes("key does not exist")) {
831
+ if (isParentMissing(res)) {
705
832
  return serverNotFoundError(srv);
706
833
  }
707
834
  return formatResult(res);
@@ -914,9 +1041,9 @@ function buildTlsConfig(fields) {
914
1041
  }
915
1042
  };
916
1043
  }
917
- function bothErrors(label, patchRes, postRes) {
1044
+ function bothErrors(label, patchRes, writeRes, writeLabel) {
918
1045
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
919
- const postErr = postRes.error || `HTTP ${postRes.status}`;
1046
+ const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
920
1047
  return {
921
1048
  isError: true,
922
1049
  content: [
@@ -924,11 +1051,97 @@ function bothErrors(label, patchRes, postRes) {
924
1051
  type: "text",
925
1052
  text: `Error: Failed to set ${label}.
926
1053
  PATCH attempt: ${patchErr}
927
- POST fallback: ${postErr}`
1054
+ ${writeLabel} fallback: ${writeErr}`
928
1055
  }
929
1056
  ]
930
1057
  };
931
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
+ }
932
1145
  function registerTlsTools(server) {
933
1146
  server.tool(
934
1147
  "caddy_tls",
@@ -951,9 +1164,9 @@ function registerTlsTools(server) {
951
1164
  };
952
1165
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
953
1166
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
954
- const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
955
- if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
956
- 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;
957
1170
  }
958
1171
  if (action === "set_acme_ca") {
959
1172
  if (!ca)
@@ -963,9 +1176,9 @@ function registerTlsTools(server) {
963
1176
  };
964
1177
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
965
1178
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
966
- const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
967
- if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
968
- 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;
969
1182
  }
970
1183
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
971
1184
  }
package/dist/server.js CHANGED
@@ -95,7 +95,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
95
95
  setEtag(path, etag);
96
96
  }
97
97
  if (isWrite && res.ok && isConfigPath) {
98
- 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
+ }
99
107
  }
100
108
  if (!res.ok) {
101
109
  if (res.status === 412) {
@@ -181,24 +189,34 @@ function getUpstreams() {
181
189
  return caddyRequest("GET", "/reverse_proxy/upstreams");
182
190
  }
183
191
  function getPki(ca = "local") {
192
+ const bad = rejectTraversal(ca);
193
+ if (bad) return Promise.resolve(bad);
184
194
  return caddyRequest("GET", `/pki/ca/${ca}`);
185
195
  }
186
196
  function getPkiCertificates(ca = "local") {
197
+ const bad = rejectTraversal(ca);
198
+ if (bad) return Promise.resolve(bad);
187
199
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
188
200
  }
189
201
  function configByIdGet(id, subpath = "") {
202
+ const badId = rejectTraversal(id);
203
+ if (badId) return Promise.resolve(badId);
190
204
  const bad = rejectTraversal(subpath);
191
205
  if (bad) return Promise.resolve(bad);
192
206
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
193
207
  return caddyRequest("GET", path);
194
208
  }
195
209
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
210
+ const badId = rejectTraversal(id);
211
+ if (badId) return Promise.resolve(badId);
196
212
  const bad = rejectTraversal(subpath);
197
213
  if (bad) return Promise.resolve(bad);
198
214
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
199
215
  return caddyRequest(method, path, value);
200
216
  }
201
217
  function configByIdDelete(id, subpath = "") {
218
+ const badId = rejectTraversal(id);
219
+ if (badId) return Promise.resolve(badId);
202
220
  const bad = rejectTraversal(subpath);
203
221
  if (bad) return Promise.resolve(bad);
204
222
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
@@ -515,6 +533,35 @@ function describeServer(raw) {
515
533
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
516
534
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
517
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
+ }
518
565
  function findAcmeEmail(policies) {
519
566
  if (!Array.isArray(policies)) return void 0;
520
567
  for (const rawPolicy of policies) {
@@ -597,10 +644,22 @@ ${lines.join("\n")}` }]
597
644
  );
598
645
  server.tool(
599
646
  "caddy_metrics",
600
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
601
- {},
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
+ },
602
654
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
603
- 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
+ }
604
663
  );
605
664
  server.tool(
606
665
  "caddy_stop",
@@ -631,7 +690,10 @@ function parseFrom(from) {
631
690
  const slashIdx = cleaned.indexOf("/");
632
691
  if (slashIdx > 0) {
633
692
  match.host = [cleaned.substring(0, slashIdx)];
634
- match.path = [cleaned.substring(slashIdx)];
693
+ const path = cleaned.substring(slashIdx);
694
+ if (path !== "/") {
695
+ match.path = [path];
696
+ }
635
697
  } else if (cleaned.startsWith("/")) {
636
698
  match.path = [cleaned];
637
699
  } else {
@@ -642,6 +704,21 @@ function parseFrom(from) {
642
704
  function cleanUpstreamAddr(addr) {
643
705
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
644
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
+ }
645
722
  function serverNotFoundError(srv) {
646
723
  return {
647
724
  isError: true,
@@ -656,14 +733,17 @@ function serverNotFoundError(srv) {
656
733
  function registerRouteTools(server) {
657
734
  server.tool(
658
735
  "caddy_reverse_proxy",
659
- "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.",
660
737
  {
661
738
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
662
739
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
663
- 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
+ )
664
744
  },
665
745
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
666
- async ({ from, to, server: srv }) => {
746
+ async ({ from, to, server: srv, id }) => {
667
747
  const match = parseFrom(from);
668
748
  const cleanedTo = to.map(cleanUpstreamAddr);
669
749
  const route = {
@@ -676,11 +756,58 @@ function registerRouteTools(server) {
676
756
  ],
677
757
  terminal: true
678
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
+ }
679
806
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
680
807
  if (res.ok) {
681
808
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
682
809
  }
683
- if (!res.ok && res.error?.includes("key does not exist")) {
810
+ if (isParentMissing(res)) {
684
811
  return serverNotFoundError(srv);
685
812
  }
686
813
  return formatResult(res);
@@ -699,7 +826,7 @@ function registerRouteTools(server) {
699
826
  async ({ match, handle, server: srv, terminal }) => {
700
827
  const route = { match, handle, terminal };
701
828
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
702
- if (!res.ok && res.error?.includes("key does not exist")) {
829
+ if (isParentMissing(res)) {
703
830
  return serverNotFoundError(srv);
704
831
  }
705
832
  return formatResult(res);
@@ -912,9 +1039,9 @@ function buildTlsConfig(fields) {
912
1039
  }
913
1040
  };
914
1041
  }
915
- function bothErrors(label, patchRes, postRes) {
1042
+ function bothErrors(label, patchRes, writeRes, writeLabel) {
916
1043
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
917
- const postErr = postRes.error || `HTTP ${postRes.status}`;
1044
+ const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
918
1045
  return {
919
1046
  isError: true,
920
1047
  content: [
@@ -922,11 +1049,97 @@ function bothErrors(label, patchRes, postRes) {
922
1049
  type: "text",
923
1050
  text: `Error: Failed to set ${label}.
924
1051
  PATCH attempt: ${patchErr}
925
- POST fallback: ${postErr}`
1052
+ ${writeLabel} fallback: ${writeErr}`
926
1053
  }
927
1054
  ]
928
1055
  };
929
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
+ }
930
1143
  function registerTlsTools(server) {
931
1144
  server.tool(
932
1145
  "caddy_tls",
@@ -949,9 +1162,9 @@ function registerTlsTools(server) {
949
1162
  };
950
1163
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
951
1164
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
952
- const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
953
- if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
954
- 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;
955
1168
  }
956
1169
  if (action === "set_acme_ca") {
957
1170
  if (!ca)
@@ -961,9 +1174,9 @@ function registerTlsTools(server) {
961
1174
  };
962
1175
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
963
1176
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
964
- const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
965
- if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
966
- 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;
967
1180
  }
968
1181
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
969
1182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.0.1",
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",