@yawlabs/caddy-mcp 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
 
8
8
  // src/api.ts
9
+ import { request as httpRequest } from "http";
9
10
  var DEFAULT_URL = "http://localhost:2019";
10
11
  var TIMEOUT = 1e4;
11
12
  var RETRY_BASE_MS = 100;
@@ -45,6 +46,15 @@ function invalidateRelated(path) {
45
46
  function getBaseUrl() {
46
47
  return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
47
48
  }
49
+ function getUnixSocketPath() {
50
+ const raw = (process.env.CADDY_ADMIN_URL || "").trim();
51
+ if (!raw) return void 0;
52
+ const urlForm = /^unix:\/\/(\/.*)$/.exec(raw);
53
+ if (urlForm) return urlForm[1];
54
+ const caddyForm = /^unix\/(\/.*)$/.exec(raw);
55
+ if (caddyForm) return caddyForm[1];
56
+ return void 0;
57
+ }
48
58
  var warnedRetryClamp = false;
49
59
  function getMaxRetries() {
50
60
  const raw = process.env.CADDY_MAX_RETRIES;
@@ -65,11 +75,12 @@ function getAdminOrigin() {
65
75
  return void 0;
66
76
  }
67
77
  }
68
- function getHeaders(contentType) {
78
+ function getHeaders(contentType, overUnixSocket = false) {
69
79
  const headers = {};
70
80
  if (contentType) headers["Content-Type"] = contentType;
71
81
  const token = process.env.CADDY_API_TOKEN;
72
82
  if (token) headers.Authorization = `Bearer ${token}`;
83
+ if (overUnixSocket) return headers;
73
84
  const origin = getAdminOrigin();
74
85
  if (origin) headers.Origin = origin;
75
86
  return headers;
@@ -102,39 +113,85 @@ function isRetryableMethod(method, path) {
102
113
  if (method !== "POST") return true;
103
114
  return !path.startsWith("/config/") && !path.startsWith("/id/");
104
115
  }
105
- async function caddyRequest(method, path, body, contentType, timeout) {
116
+ function getMalformedUnixUrl() {
117
+ const raw = (process.env.CADDY_ADMIN_URL || "").trim();
118
+ if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
119
+ return getUnixSocketPath() === void 0 ? raw : void 0;
120
+ }
121
+ async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
122
+ const malformed = getMalformedUnixUrl();
123
+ if (malformed) {
124
+ return {
125
+ ok: false,
126
+ status: 0,
127
+ error: `CADDY_ADMIN_URL="${malformed}" looks like a unix socket address but is not a recognized form. Use "unix:///absolute/path.sock" (URL form) or "unix//absolute/path.sock" (Caddy's own spelling). A single slash after "unix:", or a relative path, will not parse.`
128
+ };
129
+ }
106
130
  const maxRetries = getMaxRetries();
107
131
  let attempt = 0;
108
- let res = await attemptRequest(method, path, body, contentType, timeout);
132
+ let res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
109
133
  while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
110
134
  attempt++;
111
135
  const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
112
136
  const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
113
137
  await sleep(delay);
114
- res = await attemptRequest(method, path, body, contentType, timeout);
138
+ res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
115
139
  }
116
140
  return res;
117
141
  }
118
- async function attemptRequest(method, path, body, contentType, timeout) {
142
+ function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
143
+ return new Promise((resolve, reject) => {
144
+ const req = httpRequest({ socketPath, path, method, headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
145
+ const chunks = [];
146
+ res.on("data", (chunk) => chunks.push(chunk));
147
+ res.on("error", reject);
148
+ res.on("end", () => {
149
+ const status = res.statusCode ?? 0;
150
+ const etag = res.headers.etag;
151
+ resolve({
152
+ ok: status >= 200 && status < 300,
153
+ status,
154
+ text: Buffer.concat(chunks).toString("utf8"),
155
+ etag: typeof etag === "string" ? etag : void 0
156
+ });
157
+ });
158
+ });
159
+ req.on("error", reject);
160
+ if (body !== void 0) req.write(body);
161
+ req.end();
162
+ });
163
+ }
164
+ async function sendViaFetch(url, method, headers, body, timeoutMs) {
165
+ const res = await fetch(url, {
166
+ method,
167
+ headers,
168
+ body,
169
+ signal: AbortSignal.timeout(timeoutMs)
170
+ });
171
+ return {
172
+ ok: res.ok,
173
+ status: res.status,
174
+ text: await res.text(),
175
+ etag: res.headers.get("ETag") || void 0
176
+ };
177
+ }
178
+ async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
179
+ const socketPath = getUnixSocketPath();
119
180
  const url = `${getBaseUrl()}${path}`;
120
181
  const effectiveTimeout = timeout ?? getRequestTimeout();
121
182
  try {
122
183
  const hasBody = body !== void 0;
123
- const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
184
+ const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
124
185
  const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
125
186
  const isWrite = method !== "GET";
126
187
  if (isWrite && isConfigPath) {
127
188
  const cachedEtag = etagCache.get(path);
128
189
  if (cachedEtag) headers["If-Match"] = cachedEtag;
129
190
  }
130
- const res = await fetch(url, {
131
- method,
132
- headers,
133
- body: hasBody ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
134
- signal: AbortSignal.timeout(effectiveTimeout)
135
- });
136
- const text = await res.text();
137
- const etag = res.headers.get("ETag") || void 0;
191
+ const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
192
+ const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
193
+ const text = res.text;
194
+ const etag = res.etag;
138
195
  if (method === "GET" && etag && isConfigPath) {
139
196
  setEtag(path, etag);
140
197
  }
@@ -158,10 +215,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
158
215
  return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
159
216
  }
160
217
  if (res.status === 403 && /origin/i.test(text)) {
218
+ const hint = socketPath ? `Over a unix socket caddy-mcp deliberately sends no Origin header, because Caddy builds no default origin allowlist for a unix listener (sending one would fail against an empty list). Reaching here means admin.enforce_origin is enabled -- disable it, or move the admin endpoint to a TCP address.` : `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.`;
161
219
  return {
162
220
  ok: false,
163
221
  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.`
222
+ error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. ${hint}`
165
223
  };
166
224
  }
167
225
  return { ok: false, status: res.status, error: text };
@@ -174,17 +232,25 @@ async function attemptRequest(method, path, body, contentType, timeout) {
174
232
  }
175
233
  } catch (err) {
176
234
  const msg = err instanceof Error ? err.message : String(err);
235
+ if (socketPath && msg.includes("ENOENT")) {
236
+ return {
237
+ ok: false,
238
+ status: 0,
239
+ error: `No socket at ${socketPath} \u2014 check the path in CADDY_ADMIN_URL and that Caddy's admin endpoint is configured to listen on it.`
240
+ };
241
+ }
177
242
  if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
178
- const baseUrl = getBaseUrl();
179
- let origin = baseUrl;
180
- try {
181
- origin = new URL(baseUrl).origin;
182
- } catch {
243
+ let target = socketPath ?? getBaseUrl();
244
+ if (!socketPath) {
245
+ try {
246
+ target = new URL(target).origin;
247
+ } catch {
248
+ }
183
249
  }
184
250
  return {
185
251
  ok: false,
186
252
  status: 0,
187
- error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
253
+ error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
188
254
  };
189
255
  }
190
256
  if (msg.includes("abort") || msg.includes("timeout")) {
@@ -242,12 +308,12 @@ function getLoadTimeout() {
242
308
  return floored;
243
309
  }
244
310
  async function loadConfig(config, contentType) {
245
- const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
311
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
246
312
  if (res.ok) etagCache.clear();
247
313
  return res;
248
314
  }
249
315
  function adapt(config, adapter = "caddyfile") {
250
- return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
316
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
251
317
  }
252
318
  function stop() {
253
319
  return caddyRequest("POST", "/stop");
@@ -594,25 +660,83 @@ ${warnLines.join("\n")}` });
594
660
  import { z as z3 } from "zod";
595
661
 
596
662
  // src/snapshots.ts
663
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
664
+ import { join } from "path";
597
665
  var MAX_SNAPSHOTS = 10;
598
666
  var store = [];
667
+ function isSnapshotableConfig(data) {
668
+ return data !== null && typeof data === "object" && !Array.isArray(data);
669
+ }
670
+ var SNAPSHOT_FILE_RE = /^snapshot-(\d+)-([\w-]+)\.json$/;
671
+ function snapshotDir() {
672
+ const dir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
673
+ return dir ? dir : void 0;
674
+ }
675
+ var hydrated = false;
676
+ function hydrate() {
677
+ if (hydrated) return;
678
+ hydrated = true;
679
+ const dir = snapshotDir();
680
+ if (!dir || !existsSync(dir)) return;
681
+ try {
682
+ const names = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse().slice(0, MAX_SNAPSHOTS);
683
+ const loaded = [];
684
+ for (const name of names) {
685
+ const m = SNAPSHOT_FILE_RE.exec(name);
686
+ if (!m) continue;
687
+ try {
688
+ const config = JSON.parse(readFileSync(join(dir, name), "utf-8"));
689
+ if (!isSnapshotableConfig(config)) continue;
690
+ loaded.push({ config, timestamp: Number(m[1]), trigger: m[2] });
691
+ } catch {
692
+ }
693
+ }
694
+ loaded.sort((a, b) => b.timestamp - a.timestamp);
695
+ store.push(...loaded);
696
+ } catch {
697
+ }
698
+ }
699
+ function persist(snap) {
700
+ const dir = snapshotDir();
701
+ if (!dir) return;
702
+ try {
703
+ mkdirSync(dir, { recursive: true });
704
+ const safeTrigger = snap.trigger.replace(/[^\w-]/g, "_");
705
+ writeFileSync(
706
+ join(dir, `snapshot-${snap.timestamp}-${safeTrigger}.json`),
707
+ JSON.stringify(snap.config, null, 2),
708
+ "utf-8"
709
+ );
710
+ const files = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse();
711
+ for (const stale of files.slice(MAX_SNAPSHOTS)) {
712
+ rmSync(join(dir, stale), { force: true });
713
+ }
714
+ } catch {
715
+ }
716
+ }
599
717
  function saveSnapshot(config, trigger) {
600
- store.unshift({ config, timestamp: Date.now(), trigger });
718
+ hydrate();
719
+ let timestamp = Date.now();
720
+ if (store.length > 0 && timestamp <= store[0].timestamp) {
721
+ timestamp = store[0].timestamp + 1;
722
+ }
723
+ const snap = { config, timestamp, trigger };
724
+ store.unshift(snap);
601
725
  if (store.length > MAX_SNAPSHOTS) {
602
726
  store.length = MAX_SNAPSHOTS;
603
727
  }
728
+ persist(snap);
604
729
  }
605
730
  function listSnapshots() {
731
+ hydrate();
606
732
  return store;
607
733
  }
608
734
  function getSnapshot(index) {
735
+ hydrate();
609
736
  return store[index];
610
737
  }
611
738
 
612
739
  // src/tools/config.ts
613
- function isSnapshotableConfig(data) {
614
- return data !== null && typeof data === "object" && !Array.isArray(data);
615
- }
616
740
  function registerConfigTools(server) {
617
741
  server.tool(
618
742
  "caddy_config_get",
@@ -692,7 +816,7 @@ function registerConfigTools(server) {
692
816
  );
693
817
  server.tool(
694
818
  "caddy_revert",
695
- "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load and kept in-memory (last 10). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
819
+ "Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (last 10). By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
696
820
  {
697
821
  action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
698
822
  index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
@@ -1235,6 +1359,7 @@ function buildTlsConfig(fields) {
1235
1359
  const issuer = { module: "acme" };
1236
1360
  if (fields.email) issuer.email = fields.email;
1237
1361
  if (fields.ca) issuer.ca = fields.ca;
1362
+ if (fields.profile) issuer.profile = fields.profile;
1238
1363
  return {
1239
1364
  automation: {
1240
1365
  policies: [{ issuers: [issuer] }]
@@ -1268,6 +1393,7 @@ function mergeIssuerFields(existing, fields) {
1268
1393
  const issuer = automation.policies[0].issuers[0];
1269
1394
  if (fields.email !== void 0) issuer.email = fields.email;
1270
1395
  if (fields.ca !== void 0) issuer.ca = fields.ca;
1396
+ if (fields.profile !== void 0) issuer.profile = fields.profile;
1271
1397
  return merged;
1272
1398
  }
1273
1399
  function validateIssuerShape(tls) {
@@ -1338,47 +1464,63 @@ async function safeFallback(label, patchRes, fields) {
1338
1464
  };
1339
1465
  }
1340
1466
  const merged = mergeIssuerFields(getRes.data, fields);
1341
- const putRes = await configPut("apps/tls", merged);
1342
- if (putRes.ok) return { kind: "ok" };
1343
- return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1467
+ const mergeRes = await configPatch("apps/tls", merged);
1468
+ if (mergeRes.ok) return { kind: "ok" };
1469
+ return { kind: "tool-error", result: bothErrors(label, patchRes, mergeRes, "PATCH apps/tls") };
1470
+ }
1471
+ async function setIssuerField(field, value, label) {
1472
+ const patchRes = await configPatch(`apps/tls/automation/policies/0/issuers/0/${field}`, value);
1473
+ const ok = { content: [{ type: "text", text: `${label} set to: ${value}` }] };
1474
+ if (patchRes.ok) return ok;
1475
+ const outcome = await safeFallback(label, patchRes, { [field]: value });
1476
+ return outcome.kind === "ok" ? ok : outcome.result;
1477
+ }
1478
+ function missingArgError(text) {
1479
+ return { isError: true, content: [{ type: "text", text: `Error: ${text}` }] };
1344
1480
  }
1345
1481
  function registerTlsTools(server) {
1346
1482
  server.tool(
1347
1483
  "caddy_tls",
1348
- "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL. Works on both fresh and existing Caddy instances.",
1484
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
1349
1485
  {
1350
- action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
1486
+ action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
1351
1487
  email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
1352
- ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
1488
+ ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
1489
+ profile: z5.string().optional().describe(
1490
+ "ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy."
1491
+ )
1353
1492
  },
1354
1493
  { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1355
- async ({ action, email, ca }) => {
1494
+ async ({ action, email, ca, profile }) => {
1356
1495
  if (action === "status") {
1357
1496
  return formatResult(await configGet("apps/tls"));
1358
1497
  }
1359
- if (action === "set_email") {
1360
- if (!email)
1498
+ if (action === "ech_status") {
1499
+ const echRes = await configGet("apps/tls/ech");
1500
+ const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
1501
+ if (absent) {
1361
1502
  return {
1362
- isError: true,
1363
- content: [{ type: "text", text: "Error: email is required for set_email action" }]
1503
+ content: [
1504
+ {
1505
+ type: "text",
1506
+ text: "ECH (Encrypted ClientHello) is not configured on this instance. Requires Caddy 2.10+; enable it by applying a config with apps/tls/ech via caddy_load."
1507
+ }
1508
+ ]
1364
1509
  };
1365
- const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
1366
- if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1367
- const outcome = await safeFallback("ACME email", patchRes, { email });
1368
- if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1369
- return outcome.result;
1510
+ }
1511
+ return formatResult(echRes);
1512
+ }
1513
+ if (action === "set_email") {
1514
+ if (!email) return missingArgError("email is required for set_email action");
1515
+ return setIssuerField("email", email, "ACME email");
1370
1516
  }
1371
1517
  if (action === "set_acme_ca") {
1372
- if (!ca)
1373
- return {
1374
- isError: true,
1375
- content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
1376
- };
1377
- const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
1378
- if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1379
- const outcome = await safeFallback("ACME CA", patchRes, { ca });
1380
- if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1381
- return outcome.result;
1518
+ if (!ca) return missingArgError("ca is required for set_acme_ca action");
1519
+ return setIssuerField("ca", ca, "ACME CA");
1520
+ }
1521
+ if (action === "set_acme_profile") {
1522
+ if (!profile) return missingArgError("profile is required for set_acme_profile action");
1523
+ return setIssuerField("profile", profile, "ACME profile");
1382
1524
  }
1383
1525
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
1384
1526
  }