@yawlabs/caddy-mcp 2.4.3 → 2.5.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
- [![Follow @TokenLimitNews on X](https://img.shields.io/badge/follow-%40TokenLimitNews-000000?logo=x&logoColor=white)](https://x.com/TokenLimitNews)
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
 
@@ -275,3 +274,5 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release
275
274
  ## License
276
275
 
277
276
  MIT
277
+
278
+ [![Follow @TokenLimitNews on X](https://img.shields.io/badge/follow-%40TokenLimitNews-000000?logo=x&logoColor=white)](https://x.com/TokenLimitNews)
package/bin/caddy-mcp.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Runtime launcher for @yawlabs/caddy-mcp.
4
4
  *
5
- * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
- * process already running this file.
5
+ * Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
6
+ * Node. It never serves on an oam older than the floor below.
7
7
  *
8
8
  * Unlike npmjs-mcp, this server is NOT a zero-dependency bundle -- dist/
9
9
  * imports @modelcontextprotocol/sdk and zod from node_modules at runtime. That
@@ -15,19 +15,33 @@
15
15
  * WHY THE FALLBACK COSTS NOTHING
16
16
  * npm has already started Node to run this launcher, so falling back is a
17
17
  * plain `import()` of the server into THIS process: no extra spawn, no extra
18
- * startup, byte-identical to invoking dist/index.js directly. Discovery is
19
- * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
18
+ * startup, byte-identical to invoking dist/index.js directly. Finding the
19
+ * candidates is stat-only, so a machine without oam never pays for a
20
+ * subprocess.
20
21
  *
21
22
  * WHAT THE OAM PATH COSTS
22
- * Reaching oam through an npm `bin` means Node boots first and oam boots
23
- * second, so the launcher is slower than either runtime alone. Measured on
23
+ * Reaching oam through an npm `bin` means Node boots first, every oam binary
24
+ * found is asked for its version, and then oam boots to serve -- so the
25
+ * launcher is slower than pointing a host at oam directly. Measured on
24
26
  * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
25
- * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
26
- * launcher is the slowest path -- it exists for `npx` convenience.
27
+ * oam 116ms, node 172ms, launcher 243ms. It exists for `npx` convenience.
27
28
  *
28
29
  * For an MCP host config, point straight at oam and skip this file:
29
30
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
30
31
  *
32
+ * WHICH OAM
33
+ * OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
34
+ * discovery can see -- the installed locations, then PATH -- is asked for its
35
+ * version, and the NEWEST one at or above the floor wins; a tie keeps search
36
+ * order. Taking the first binary found instead let a stale copy early in the
37
+ * search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
38
+ * and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
39
+ * are searched first.
40
+ *
41
+ * An OAM_BIN that does not exist, is below the floor, or will not run is named
42
+ * on stderr and discovery carries on. It used to stop everything: a typo in
43
+ * OAM_BIN meant Node, with no hint why.
44
+ *
31
45
  * ALREADY RUNNING ON OAM
32
46
  * A host can resolve this package's `bin` and launch `oam run <this file>`
33
47
  * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
@@ -39,23 +53,35 @@
39
53
  * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
40
54
  * is not consulted on that path: the host has already chosen which oam runs.
41
55
  *
42
- * Two cases still take the discovery path, deliberately. CADDY_MCP_SANDBOX=1,
43
- * because `--permission` is a process-level flag that only a FRESH oam can
44
- * apply -- serving in-process on the host would drop the sandbox without a
45
- * word, a security downgrade dressed up as an optimisation. And a host oam
46
- * below the floor, which takes the discovery path exactly as it always did.
56
+ * CADDY_MCP_SANDBOX=1 still takes the discovery path on such a host,
57
+ * deliberately: `--permission` is a process-level flag that only a FRESH oam
58
+ * can apply, so serving in-process on the host would drop the sandbox without
59
+ * a word -- a security downgrade dressed up as an optimisation.
60
+ *
61
+ * A host oam BELOW the floor never serves. It used to, whenever discovery came
62
+ * up empty. It now hands the server off to the newest usable oam, or to Node
63
+ * found on PATH, or exits with an error when there is neither.
64
+ *
65
+ * Every handoff from an oam host PIPES stdio rather than inheriting it. Before
66
+ * 0.9.0 oam treated `stdio: 'inherit'` as `'pipe'`, so an inherited handoff
67
+ * from such a host connected the child to pipes nobody reads: measured with a
68
+ * real oam 0.8.2 host, the MCP handshake never answered. Piping the streams
69
+ * explicitly completes it, to both oam and Node. The sandbox spawn from a
70
+ * supported oam host pipes too -- one rule for every oam host, verified with a
71
+ * real handshake on 0.15.2. A Node host keeps `inherit`, which hands over the
72
+ * same fds untouched.
47
73
  *
48
74
  * The sandbox asks for a spawn; it does not guarantee one. Discovery can still
49
- * come up empty -- no oam binary, one below the floor or unreadable, a spawn
50
- * that fails -- and under CADDY_MCP_RUNTIME=auto that falls back exactly as it
51
- * does on Node: the server runs in THIS process WITHOUT `--permission`. No oam
52
- * (and no .cmd shim) and a failed spawn fall back silently; the too-old or
53
- * unreadable binary and .cmd-shim cases print a note that does not mention the
54
- * sandbox. Only CADDY_MCP_RUNTIME=oam turns that miss into a hard failure, so
55
- * pair it with CADDY_MCP_SANDBOX=1 when the sandbox has to hold.
56
- * CADDY_MCP_RUNTIME=node ignores the sandbox entirely.
57
- *
58
- * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
75
+ * come up empty -- no usable oam binary, or a spawn that fails -- and under
76
+ * CADDY_MCP_RUNTIME=auto the server then runs WITHOUT `--permission`: in THIS
77
+ * process on Node or on a host oam at the floor, handed off to Node from a host
78
+ * oam below it. The stderr notes name a passed-over OAM_BIN, found binary or
79
+ * .cmd shim, and a failed spawn, but none of them mentions the sandbox, and
80
+ * with nothing found at all the fallback is silent. Only CADDY_MCP_RUNTIME=oam
81
+ * turns that miss into a hard failure, so pair it with CADDY_MCP_SANDBOX=1 when
82
+ * the sandbox has to hold. CADDY_MCP_RUNTIME=node ignores the sandbox entirely.
83
+ *
84
+ * THE `--permission` SANDBOX (opt-in)
59
85
  * `CADDY_MCP_SANDBOX=1` runs the server under oam's permission model when an
60
86
  * oam binary is found and launched -- see ALREADY RUNNING ON OAM for the
61
87
  * fallback that runs it unsandboxed, and how to refuse that instead.
@@ -86,34 +112,43 @@
86
112
  * "unauthenticated". The env list is derived from the shipped bundle.
87
113
  *
88
114
  * MINIMUM OAM VERSION
89
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
90
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
91
- * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
92
- * behaved as `'pipe'`. This server spawns nothing, so the floor is
93
- * enforced for consistency across @yawlabs/*-mcp rather than because this
94
- * launcher was exposed.
95
- * An older oam is not an error: the launcher falls back to Node and says so on
96
- * stderr. Pinning the floor here is what makes that fallback automatic.
115
+ * The latest oam release, 0.15.2 -- bump OAM_MIN when oam ships a newer one.
116
+ * Only the current oam is used and verified; an older one is passed over. The
117
+ * floor is not cosmetic: before 0.9.0 `child_process.execFile` ran its
118
+ * arguments through a SHELL, `exec` accepted `timeout` and ignored it,
119
+ * `spawnSync` truncated at `maxBuffer` while reporting success, and
120
+ * `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. The server itself
121
+ * spawns nothing, but this launcher does, and the last of those reached it: an
122
+ * old oam host's inherited handoff never answered the MCP handshake (see
123
+ * ALREADY RUNNING ON OAM).
97
124
  *
98
125
  * SELECTION
99
- * CADDY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
100
- * (already running on oam satisfies it, except
101
- * under CADDY_MCP_SANDBOX=1)
102
- * CADDY_MCP_RUNTIME=node never use oam
103
- * CADDY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
104
- * CADDY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+); under
126
+ * CADDY_MCP_RUNTIME=auto newest usable oam, else Node (default)
127
+ * CADDY_MCP_RUNTIME=oam newest usable oam, else exit with an error
128
+ * (already running on oam at the floor satisfies
129
+ * it, except under CADDY_MCP_SANDBOX=1)
130
+ * CADDY_MCP_RUNTIME=node Node: in THIS process on Node, handed off to Node
131
+ * on PATH when THIS process is oam
132
+ * CADDY_MCP_SANDBOX=1 run the spawned oam under --permission; under
105
133
  * auto a discovery miss still runs unsandboxed
106
- * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
134
+ * OAM_BIN=/path/to/oam use this oam when it is usable, before discovery
135
+ * The runtime value is case-insensitive; anything else behaves like `auto`.
107
136
  */
108
137
 
109
138
  import { execFileSync, spawn } from "node:child_process";
110
- import { existsSync } from "node:fs";
139
+ import { existsSync, realpathSync } from "node:fs";
111
140
  import { constants, homedir } from "node:os";
112
141
  import { delimiter, join } from "node:path";
113
142
  import { fileURLToPath } from "node:url";
114
143
 
115
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
116
- const OAM_MIN = [0, 9, 0];
144
+ /** Oldest oam this server is served on. See MINIMUM OAM VERSION above. */
145
+ const OAM_MIN = [0, 15, 2];
146
+
147
+ /**
148
+ * Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
149
+ * the bound only exists so a wedged binary on PATH cannot hang the launch.
150
+ */
151
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
117
152
 
118
153
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
119
154
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -123,55 +158,71 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
123
158
  const isWin = process.platform === "win32";
124
159
  const exe = isWin ? "oam.exe" : "oam";
125
160
 
126
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
127
- function findOam() {
128
- // 1. Explicit override wins and is never second-guessed.
129
- const override = process.env.OAM_BIN;
130
- if (override) return existsSync(override) ? override : null;
131
-
132
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
133
- // usually has oam/target/release on PATH, and a build directory is the
134
- // wrong thing for a user-facing launcher to bind to: cargo replaces the
135
- // binary underneath running processes, and the dev build is not the
136
- // release the user installed. Preferring the installed copy makes the
137
- // default path "what a normal user has", and OAM_BIN remains the way to
138
- // point deliberately at a dev build.
139
- //
140
- // Both forms are checked on Windows: the installer defaults to
141
- // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
142
- // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
143
- // install.
161
+ /** Identity for de-duplicating paths: resolved, and case-folded on Windows. */
162
+ function pathKey(p) {
163
+ let key = p;
164
+ try {
165
+ key = realpathSync(p);
166
+ } catch {
167
+ // Unresolvable: fall back to the literal path.
168
+ }
169
+ return isWin ? key.toLowerCase() : key;
170
+ }
171
+
172
+ /**
173
+ * Every oam binary discovery can see, in search order, de-duplicated. Stat-only,
174
+ * never a subprocess.
175
+ *
176
+ * Installed locations come BEFORE PATH, so when two binaries report the same
177
+ * version the installed copy wins the tie. Someone who develops oam itself
178
+ * usually has oam/target/release on PATH, and a build directory is the wrong
179
+ * thing to prefer at equal versions: cargo replaces the binary underneath
180
+ * running processes, and the dev build is not the release the user installed.
181
+ * A NEWER build on PATH still wins -- the newest usable oam is the rule -- and
182
+ * OAM_BIN remains the way to point deliberately at one.
183
+ *
184
+ * Both installed forms are checked on Windows: the installer defaults to
185
+ * %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
186
+ * OAM_INSTALL_DIR can pick either, so checking one silently misses a real
187
+ * install.
188
+ *
189
+ * PATH is resolved manually rather than by spawning `which`/`where`, which
190
+ * would cost a subprocess on every launch just to decide whether to spawn.
191
+ *
192
+ * Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
193
+ * run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
194
+ * spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking the
195
+ * full PATHEXT list would only collect paths this launcher cannot execute.
196
+ * Discovery has to agree with execution. A skipped shim is still reported --
197
+ * see findOamShim.
198
+ *
199
+ * scripts/runtime.mjs carries its OWN findOam that DOES walk the full PATHEXT.
200
+ * That is a deliberate difference, not drift: it serves the build and
201
+ * typecheck scripts, drops whatever fails its execFileSync probe, and has no
202
+ * diagnostic to give, so a wider walk there only ever finds more. Here a
203
+ * collected .cmd would cost a probe that cannot succeed, and add a "could not
204
+ * be run" note beside the shim note that actually says what to do about it.
205
+ * Same question, different constraint; change one and re-read the other.
206
+ */
207
+ function discoverOamPaths() {
144
208
  const installed = [join(homedir(), ".oam", "bin", exe)];
145
209
  if (isWin) {
146
210
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
147
211
  }
148
- for (const candidate of installed) {
149
- if (existsSync(candidate)) return candidate;
212
+ const onPath = (process.env.PATH ?? "")
213
+ .split(delimiter)
214
+ .filter(Boolean)
215
+ .map((dir) => join(dir, exe));
216
+ const seen = new Set();
217
+ const found = [];
218
+ for (const candidate of [...installed, ...onPath]) {
219
+ if (!existsSync(candidate)) continue;
220
+ const key = pathKey(candidate);
221
+ if (seen.has(key)) continue;
222
+ seen.add(key);
223
+ found.push(candidate);
150
224
  }
151
-
152
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
153
- // would cost a subprocess on every launch just to decide whether to spawn.
154
- // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
155
- // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
156
- // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
157
- // the full PATHEXT list would hand back a path this launcher cannot execute.
158
- // Discovery has to agree with execution. A skipped shim is still reported --
159
- // see findOamShim.
160
- //
161
- // scripts/runtime.mjs carries its OWN findOam that DOES walk the full PATHEXT.
162
- // That is a deliberate difference, not drift: it probes each candidate with
163
- // execFileSync before returning it, so a shim it cannot run is dropped anyway
164
- // and a wider walk only ever finds more. This one is stat-only on the hot
165
- // launch path -- no probe to filter with -- so whatever it returns it must be
166
- // able to spawn. Same question, different constraint; change one and re-read
167
- // the other.
168
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
169
- if (!dir) continue;
170
- const candidate = join(dir, exe);
171
- if (existsSync(candidate)) return candidate;
172
- }
173
-
174
- return null;
225
+ return found;
175
226
  }
176
227
 
177
228
  /**
@@ -194,10 +245,12 @@ function oamVersion(cmd) {
194
245
  const out = execFileSync(cmd, ["--version"], {
195
246
  encoding: "utf-8",
196
247
  stdio: ["ignore", "pipe", "ignore"],
248
+ timeout: VERSION_PROBE_TIMEOUT_MS,
249
+ windowsHide: true,
197
250
  });
198
251
  return parseVersion(out);
199
252
  } catch {
200
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
253
+ // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
201
254
  return null;
202
255
  }
203
256
  }
@@ -212,25 +265,47 @@ function atLeast(v, min) {
212
265
  return true;
213
266
  }
214
267
 
268
+ /**
269
+ * The newest candidate at or above the floor, or null. `candidates` is
270
+ * `{ path, version }[]` in search order, `version` null when unreadable.
271
+ * Strictly-greater replaces, so a tie keeps the earlier candidate.
272
+ *
273
+ * Pure on purpose, like runtimePlan: the choice is testable without binaries.
274
+ */
275
+ function pickNewest(candidates) {
276
+ let best = null;
277
+ for (const candidate of candidates) {
278
+ if (!atLeast(candidate.version, OAM_MIN)) continue;
279
+ if (!best || !atLeast(best.version, candidate.version)) best = candidate;
280
+ }
281
+ return best;
282
+ }
283
+
215
284
  /**
216
285
  * Where the server runs, decided BEFORE any discovery:
217
- * "in-process" import it into THIS process
218
- * "discover" find an oam binary, gate its version, spawn it -- or fall
219
- * back to Node in-process when that fails
220
- *
221
- * `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
222
- * Node every mode but `node` is the discovery path it always was. `sandbox`
223
- * is whether a spawn would carry flags only a fresh oam can apply; see ALREADY
224
- * RUNNING ON OAM above for why that alone forces discovery -- and why discovery
225
- * is not a guaranteed spawn, since its miss still falls back in-process. The
286
+ * "in-process" import it into THIS process
287
+ * "discover" choose an oam and spawn it, or fall back when none is usable
288
+ * "handoff-node" hand it off to Node on PATH: THIS process is an oam and
289
+ * Node was asked for
290
+ *
291
+ * `hostOam` is `process.versions.oam`: oam's own key, absent on Node. An oam
292
+ * host whose version cannot be read is treated as below the floor -- it never
293
+ * proved it is a supported oam. `sandbox` is whether a spawn would carry flags
294
+ * only a fresh oam can apply; see ALREADY RUNNING ON OAM above for why that
295
+ * alone forces discovery -- and why discovery is not a guaranteed spawn. The
226
296
  * floor is OAM_MIN itself, not a parameter, so a host oam and a discovered one
227
297
  * can never be held to different minimums.
228
298
  *
299
+ * A host oam below the floor gets "discover", not a handoff straight to Node:
300
+ * a newer oam may still be found, and when none is, the fallback hands off to
301
+ * Node rather than serving on the host.
302
+ *
229
303
  * Pure on purpose: every input is passed in, so the whole decision is testable
230
304
  * without booting a runtime.
231
305
  */
232
306
  function runtimePlan({ mode, hostOam, sandbox }) {
233
- if (mode === "node") return "in-process";
307
+ const onOam = hostOam !== undefined;
308
+ if (mode === "node") return onOam ? "handoff-node" : "in-process";
234
309
  if (sandbox) return "discover";
235
310
  return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
236
311
  }
@@ -282,7 +357,7 @@ function sandboxFlags() {
282
357
  // Omitting the flag DENIES the category (oam reads an absent --allow-net as
283
358
  // false, a bare one as "*"), and denial costs nothing here: oam has no unix
284
359
  // socket transport at all, so api.ts's node:http `socketPath` dial cannot work
285
- // under oam whether the grant is open or closed. Verified against oam 0.9.0 --
360
+ // under oam whether the grant is open or closed. Re-verified against oam 0.15.2 --
286
361
  // bare grant lets an unrelated host through, omitted grant denies it.
287
362
  //
288
363
  // This mirrors getMalformedUnixUrl's predicate in src/api.ts, NOT the stricter
@@ -408,6 +483,58 @@ function findOamShim() {
408
483
  return null;
409
484
  }
410
485
 
486
+ /** A Node binary on PATH, or null. Stat-only; used only when THIS process is oam. */
487
+ function findNodeOnPath() {
488
+ const name = isWin ? "node.exe" : "node";
489
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
490
+ if (!dir) continue;
491
+ const candidate = join(dir, name);
492
+ if (existsSync(candidate)) return candidate;
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /**
498
+ * Why a candidate was passed over, for stderr. Two causes need two details: a
499
+ * null version is NOT "old" -- the binary could not be run at all (not
500
+ * executable, wrong arch, wedged, deleted between the stat and the probe) or
501
+ * its output did not parse -- so it must not be described as one that needs
502
+ * an update.
503
+ */
504
+ function unusableReason(path, version, label = path) {
505
+ const min = OAM_MIN.join(".");
506
+ return version
507
+ ? `${label} is oam ${version.join(".")}, older than ${min}`
508
+ : `${label} could not be run, or did not report a version this launcher understands`;
509
+ }
510
+
511
+ /**
512
+ * Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
513
+ * binary. Returns the choice (or null) plus stderr notes: `overrideNote` about
514
+ * an unusable OAM_BIN, and `skipped` describing what was found and rejected
515
+ * when nothing was usable.
516
+ */
517
+ function chooseOam() {
518
+ const override = process.env.OAM_BIN;
519
+ let overrideNote = null;
520
+ if (override) {
521
+ if (!existsSync(override)) {
522
+ overrideNote = `OAM_BIN=${override} does not exist`;
523
+ } else {
524
+ const version = oamVersion(override);
525
+ if (atLeast(version, OAM_MIN)) return { chosen: { path: override, version }, overrideNote, skipped: [] };
526
+ overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
527
+ }
528
+ }
529
+ const overrideKey = override ? pathKey(override) : null;
530
+ const candidates = discoverOamPaths()
531
+ .filter((path) => pathKey(path) !== overrideKey)
532
+ .map((path) => ({ path, version: oamVersion(path) }));
533
+ const chosen = pickNewest(candidates);
534
+ const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
535
+ return { chosen, overrideNote, skipped };
536
+ }
537
+
411
538
  /** Run the server in THIS process. The zero-overhead fallback. */
412
539
  async function runInProcess() {
413
540
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -424,195 +551,251 @@ async function runInProcess() {
424
551
  await import(SERVER_URL.href);
425
552
  }
426
553
 
554
+ // ONE reporter for every failed in-process fallback. runInProcess() is a bare
555
+ // import() that rejects when dist/index.js is missing, and at ESM top level an
556
+ // unhandled rejection is an uncaught exception -- replacing this launcher's
557
+ // diagnostic with a raw stack trace.
558
+ const fallbackFailed = (e) => {
559
+ process.stderr.write(`caddy-mcp: fallback to Node failed (${e?.message ?? e})\n`);
560
+ process.exitCode = 1;
561
+ };
562
+
563
+ /**
564
+ * Spawn the server in a child runtime and mirror its lifetime.
565
+ *
566
+ * `onLaunchFailed(err)` runs when the child could not be started at all; it is
567
+ * never called once the child is running, which would double-start the server
568
+ * on the same stdio.
569
+ */
570
+ async function launchChild(cmd, args, onLaunchFailed) {
571
+ // THIS process being an oam means one below the floor, or a supported one
572
+ // spawning a fresh oam for CADDY_MCP_SANDBOX=1. Below the floor
573
+ // `stdio: 'inherit'` does not hand over the fds, so pipe explicitly on every
574
+ // oam host; see ALREADY RUNNING ON OAM.
575
+ const piped = process.versions.oam !== undefined;
576
+ let child = null;
577
+ try {
578
+ child = spawn(cmd, args, {
579
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
580
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
581
+ // server's shutdown path. Piping preserves both as well: bytes are copied
582
+ // unchanged, and stdin's end propagates to the child.
583
+ stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
584
+ env: process.env,
585
+ windowsHide: true,
586
+ });
587
+ } catch (err) {
588
+ // spawn() THROWS for some failures instead of emitting 'error', and the
589
+ // 'error' listener is registered AFTER this call, so it can never observe
590
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
591
+ // instead of falling back.
592
+ await onLaunchFailed(err).catch(fallbackFailed);
593
+ return;
594
+ }
595
+
596
+ // If the runtime cannot be executed at all (deleted between the version probe
597
+ // and the spawn, wrong arch, permission), fall back rather than failing the
598
+ // whole server. `spawned` prevents falling back AFTER the child started.
599
+ //
600
+ // Everything that assumes a live child waits for 'spawn'. A failed spawn
601
+ // still emits 'close' (after 'error', with the negative errno as its code), so
602
+ // an unguarded close handler would process.exit() out from under the fallback
603
+ // onLaunchFailed has just started -- and stdin piped into a child that never
604
+ // ran would swallow the host's first bytes before the fallback could read
605
+ // them. Until 'spawn', process.stdin has no reader and simply stays paused.
606
+ let spawned = false;
607
+ child.on("spawn", () => {
608
+ spawned = true;
609
+ if (piped) {
610
+ process.stdin.pipe(child.stdin);
611
+ child.stdout.pipe(process.stdout);
612
+ child.stderr.pipe(process.stderr);
613
+ }
614
+ forwardSignals();
615
+ });
616
+ child.on("error", (err) => {
617
+ if (spawned) return;
618
+ onLaunchFailed(err).catch(fallbackFailed);
619
+ });
620
+ // A child that exits before reading everything closes its stdin; the
621
+ // resulting EPIPE is not worth crashing over.
622
+ child.stdin?.on("error", () => {});
623
+
624
+ // Forward termination so the server's own shutdown path runs in the child
625
+ // rather than the child being orphaned.
626
+ //
627
+ // Registering ANY handler for these suppresses Node's default
628
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
629
+ // `child.killed` only records that kill() was CALLED, never that the child
630
+ // is gone, so gating on it swallows every signal after the first and wedges
631
+ // the launcher with no escape hatch.
632
+ //
633
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
634
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
635
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
636
+ // "a second signal" as impatience hard-kills a child that is already
637
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
638
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
639
+ // a wall-clock step cannot mis-gate the window either.
640
+ //
641
+ // POSIX vs Windows, and why we do NOT forward on Windows.
642
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
643
+ // is what lets the child run its shutdown. On Windows there are no POSIX
644
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
645
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
646
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
647
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
648
+ // child's process.on("exit") cleanup. The console has already notified the
649
+ // child, so on Windows the timer below is the only kill we issue.
650
+ // 5s, not 2s. This is a HARD kill of a server that may be mid-shutdown --
651
+ // flushing a large config, finishing a TLS provision -- and the cost of
652
+ // waiting too long (a wedged child lingers a few extra seconds) is far
653
+ // smaller than the cost of cutting a legitimate shutdown short. The window
654
+ // only ever elapses when the child has NOT exited on its own.
655
+ const ESCALATE_AFTER_MS = 5000;
656
+ let escalation = null;
657
+ function forwardSignals() {
658
+ for (const sig of ["SIGINT", "SIGTERM"]) {
659
+ process.on(sig, () => {
660
+ // No try/catch: kill() on an already-exited child returns false, it does
661
+ // not throw. It throws only for a signal the platform does not know,
662
+ // which SIGINT/SIGTERM/SIGKILL never are.
663
+ if (!isWin) child.kill(sig);
664
+ if (escalation) return; // already counting down; further signals are noise
665
+ escalation = setTimeout(() => {
666
+ // Still here after its grace window. Stop waiting on it.
667
+ child.kill("SIGKILL");
668
+ process.exit(128 + (constants.signals[sig] ?? 15));
669
+ }, ESCALATE_AFTER_MS);
670
+ });
671
+ }
672
+ }
673
+
674
+ // Piped: wait for 'close', so the child's last stdout bytes are copied out
675
+ // before this process exits. Inherited: 'exit' is enough, the fds were never
676
+ // ours to drain. Either way, only for a child that actually ran -- see the
677
+ // 'spawn' handler above.
678
+ child.on(piped ? "close" : "exit", (code, signal) => {
679
+ if (!spawned) return;
680
+ if (escalation) clearTimeout(escalation);
681
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
682
+ // conventional shell exit status rather than a bare 0.
683
+ if (signal) {
684
+ process.exit(128 + (constants.signals[signal] ?? 15));
685
+ }
686
+ process.exit(code ?? 0);
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Hand the server to Node on PATH. Only reachable when THIS process is oam --
692
+ * one below the floor with no newer oam, or any oam under
693
+ * CADDY_MCP_RUNTIME=node -- so there is no in-process option left.
694
+ */
695
+ async function handOffToNode(reason) {
696
+ const node = findNodeOnPath();
697
+ if (!node) {
698
+ await errSync(
699
+ `caddy-mcp: ${reason}, and no Node was found on PATH to run the server instead.\n` +
700
+ `Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer, or launch this command with node.\n`,
701
+ );
702
+ process.exit(1);
703
+ }
704
+ if (reason) await errSync(`caddy-mcp: ${reason}; running on ${node} instead.\n`);
705
+ await launchChild(node, [SERVER_ENTRY, ...process.argv.slice(2)], async (err) => {
706
+ await errSync(`caddy-mcp: failed to launch Node at ${node} (${err?.message ?? err})\n`);
707
+ process.exit(1);
708
+ });
709
+ }
710
+
711
+ /**
712
+ * True when THIS process may serve without spawning: it is Node, or an oam at
713
+ * or above the floor. The second is reachable after discovery only under
714
+ * CADDY_MCP_SANDBOX=1, and serves WITHOUT --permission; see ALREADY RUNNING ON
715
+ * OAM.
716
+ */
717
+ function hostMayServe(hostOam) {
718
+ return hostOam === undefined || atLeast(parseVersion(hostOam), OAM_MIN);
719
+ }
720
+
721
+ /** What the fallback below will do, for the tail of a stderr note. */
722
+ function fallbackTail(hostOam) {
723
+ return hostOam !== undefined && hostMayServe(hostOam)
724
+ ? `serving in this process (oam ${hostOam}) instead`
725
+ : "using Node instead";
726
+ }
727
+
728
+ /**
729
+ * No oam was spawned, under a mode that allows serving without one. Serve in
730
+ * THIS process when it may, otherwise -- a host oam below the floor -- hand off
731
+ * to Node.
732
+ */
733
+ async function fallBack(hostOam) {
734
+ if (hostMayServe(hostOam)) {
735
+ await runInProcess();
736
+ return;
737
+ }
738
+ await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and no newer oam was found`);
739
+ }
740
+
427
741
  const mode = (process.env.CADDY_MCP_RUNTIME ?? "auto").toLowerCase();
742
+ const hostOam = process.versions.oam;
428
743
 
429
744
  // The sandbox is read off the grant list rather than CADDY_MCP_SANDBOX, so
430
745
  // "would the spawn carry --permission" cannot drift from what the spawn below
431
746
  // actually passes.
432
- const plan = runtimePlan({ mode, hostOam: process.versions.oam, sandbox: sandboxFlags().length > 0 });
747
+ const plan = runtimePlan({ mode, hostOam, sandbox: sandboxFlags().length > 0 });
433
748
 
434
749
  if (plan === "in-process") {
435
750
  await runInProcess();
751
+ } else if (plan === "handoff-node") {
752
+ const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
753
+ await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
436
754
  } else {
437
- const oam = findOam();
438
- // Read the version ONCE, and only when discovery found something: the gate
439
- // below has to tell "too old" apart from "could not be read at all", and
440
- // re-probing inside the branch would cost a second subprocess.
441
- //
442
- // This is the first subprocess the launcher runs -- discovery itself is
443
- // stat-only. Paid on every launch that finds an oam, including the ones
444
- // that go on to fall back to Node.
445
- const found = oam ? oamVersion(oam) : null;
446
-
447
- if (!oam) {
448
- // An oam-named .cmd/.bat on PATH is a real install in a shape this
449
- // launcher cannot spawn. Naming it turns "no oam binary was found" --
450
- // which reads as "install oam", the one thing that will not help --
451
- // into something the user can act on.
452
- const oamShim = findOamShim();
453
- const shimNote = oamShim
454
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
455
- "Install the native oam binary, or point OAM_BIN at one.\n"
456
- : "";
755
+ const { chosen, overrideNote, skipped } = chooseOam();
756
+
757
+ if (chosen) {
758
+ if (overrideNote)
759
+ await errSync(`caddy-mcp: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join(".")}).\n`);
760
+ // `--` separates oam's own flags from the script's argv, so `caddy-mcp
761
+ // --version` and any host-supplied flags survive the hop unchanged. The
762
+ // sandbox flags are process-level, so they go BEFORE `run`.
763
+ await launchChild(
764
+ chosen.path,
765
+ [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)],
766
+ async (err) => {
767
+ if (mode === "oam") {
768
+ await errSync(`caddy-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err})\n`);
769
+ process.exit(1);
770
+ }
771
+ await errSync(
772
+ `caddy-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err}); ${fallbackTail(hostOam)}.\n`,
773
+ );
774
+ await fallBack(hostOam);
775
+ },
776
+ );
777
+ } else {
778
+ const shim = findOamShim();
779
+ const notes = [
780
+ ...(overrideNote ? [overrideNote] : []),
781
+ ...skipped,
782
+ ...(shim
783
+ ? [
784
+ `found ${shim}, but Node cannot execute a .cmd/.bat directly -- install the native oam binary, or point OAM_BIN at one`,
785
+ ]
786
+ : []),
787
+ ];
457
788
  if (mode === "oam") {
458
- // Explicitly demanded, so this is a real misconfiguration. writeSync
459
- // because stderr is async for TTYs/pipes on Windows and process.exit
460
- // truncates pending writes.
461
- const { writeSync } = await import("node:fs");
462
- writeSync(
463
- 2,
464
- "caddy-mcp: CADDY_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
465
- shimNote +
466
- "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
789
+ await errSync(
790
+ `caddy-mcp: CADDY_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
791
+ notes.map((note) => ` ${note}\n`).join("") +
792
+ "Install or update from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
467
793
  );
468
794
  process.exit(1);
469
795
  }
470
796
  // auto: falling back is correct, but silence is how someone never learns
471
- // their oam install is a shape this launcher skips.
472
- if (oamShim) await errSync(`caddy-mcp: ${shimNote}Using Node instead.\n`);
473
- await runInProcess();
474
- } else if (!atLeast(found, OAM_MIN)) {
475
- const min = OAM_MIN.join(".");
476
- // Two different causes reach this branch and they need different
477
- // remedies. `found === null` is NOT "old": oamVersion returns null when
478
- // the binary could not be run at all (not executable, wrong arch, a
479
- // .cmd/.bat Node refuses, deleted between the stat and the probe) or
480
- // when its --version output did not parse. Telling that user to
481
- // `oam self-update` sends them after the one cause it definitely is not.
482
- const detail = found
483
- ? `${oam} is oam ${found.join(".")}, older than ${min}`
484
- : `${oam} could not be run, or did not report a version this launcher understands`;
485
- const remedy = found
486
- ? "Run `oam self-update`, or use CADDY_MCP_RUNTIME=node.\n"
487
- : "Check that it is an executable oam binary for this platform, or use CADDY_MCP_RUNTIME=node.\n";
488
- if (mode === "oam") {
489
- await errSync(`caddy-mcp: CADDY_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
490
- process.exit(1);
491
- }
492
- // auto: neither cause is worth failing over -- prefer Node. Say so,
493
- // because a silent downgrade is how someone keeps running an oam they
494
- // meant to update, or never learns their oam is unexecutable.
495
- await errSync(`caddy-mcp: ${detail}; using Node instead.\n`);
496
- await runInProcess();
497
- } else {
498
- // `--` separates oam's own flags from the script's argv, so `caddy-mcp
499
- // --version` and any host-supplied flags survive the hop unchanged.
500
- // Every "oam could not be executed" outcome lands here: the synchronous
501
- // throw from spawn() and the async 'error' event mean the same thing and
502
- // must degrade the same way, so the handling lives in one place.
503
- // errSync rather than process.stderr.write because stderr is async for
504
- // TTYs and pipes on Windows and the process.exit below truncates pending
505
- // writes.
506
- const launchFailed = async (err) => {
507
- if (mode === "oam") {
508
- await errSync(`caddy-mcp: failed to launch oam (${err?.message ?? err})\n`);
509
- process.exit(1);
510
- }
511
- await runInProcess();
512
- };
513
-
514
- // ONE reporter shared by both launchFailed call sites, so the sync-throw
515
- // path and the 'error'-event path cannot drift apart. Either can reject:
516
- // runInProcess() is a bare import() that rejects when dist/index.js is
517
- // missing, and at ESM top level an unhandled rejection is an uncaught
518
- // exception -- the exact failure this handling exists to prevent.
519
- const fallbackFailed = (e) => {
520
- process.stderr.write(`caddy-mcp: fallback to Node failed (${e?.message ?? e})\n`);
521
- process.exitCode = 1;
522
- };
523
-
524
- let child = null;
525
- try {
526
- child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
527
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
528
- // stdin/stdout is untouched and the host's stdin-close still reaches the
529
- // server's shutdown path.
530
- stdio: "inherit",
531
- env: process.env,
532
- windowsHide: true,
533
- });
534
- } catch (err) {
535
- // spawn() THROWS for some failures instead of emitting 'error', and the
536
- // 'error' listener is registered AFTER this call, so it can never observe
537
- // one -- an uncaught throw here kills the launcher with a raw stack trace
538
- // instead of falling back to Node.
539
- await launchFailed(err).catch(fallbackFailed);
540
- }
541
-
542
- if (child) {
543
- // If oam cannot be executed at all (deleted between the stat and the spawn,
544
- // wrong arch, permission), fall back rather than failing the whole server.
545
- // `spawned` prevents falling back AFTER the child started, which would
546
- // double-start the server on the same stdio.
547
- let spawned = false;
548
- child.on("spawn", () => {
549
- spawned = true;
550
- });
551
- child.on("error", (err) => {
552
- if (spawned) return;
553
- // Handle the rejection instead of discarding it: a failing in-process
554
- // fallback would otherwise escape as an unhandled rejection, replacing
555
- // this launcher's diagnostic with a raw stack trace.
556
- launchFailed(err).catch(fallbackFailed);
557
- });
558
-
559
- // Forward termination so the server's own shutdown path runs in the child
560
- // rather than the child being orphaned.
561
- //
562
- // Registering ANY handler for these suppresses Node's default
563
- // terminate-on-signal, so the parent's exit has to be arranged explicitly.
564
- // `child.killed` only records that kill() was CALLED, never that the child
565
- // is gone, so gating on it swallows every signal after the first and wedges
566
- // the launcher with no escape hatch.
567
- //
568
- // Escalation is driven by a TIMER, not by counting signals. Counting is
569
- // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
570
- // apart, and a terminal Ctrl-C reaches the whole process group, so reading
571
- // "a second signal" as impatience hard-kills a child that is already
572
- // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
573
- // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
574
- // a wall-clock step cannot mis-gate the window either.
575
- //
576
- // POSIX vs Windows, and why we do NOT forward on Windows.
577
- // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
578
- // is what lets the child run its shutdown. On Windows there are no POSIX
579
- // signals: child.kill IGNORES the name and calls TerminateProcess -- an
580
- // immediate hard kill (verified: a child with a SIGTERM handler never runs
581
- // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
582
- // graceful shutdown the console's own Ctrl-C just started, skipping the
583
- // child's process.on("exit") cleanup. The console has already notified the
584
- // child, so on Windows the timer below is the only kill we issue.
585
- // 5s, not 2s. This is a HARD kill of a server that may be mid-shutdown --
586
- // flushing a large config, finishing a TLS provision -- and the cost of
587
- // waiting too long (a wedged child lingers a few extra seconds) is far
588
- // smaller than the cost of cutting a legitimate shutdown short. The window
589
- // only ever elapses when the child has NOT exited on its own.
590
- const ESCALATE_AFTER_MS = 5000;
591
- let escalation = null;
592
- for (const sig of ["SIGINT", "SIGTERM"]) {
593
- process.on(sig, () => {
594
- // No try/catch: kill() on an already-exited child returns false, it does
595
- // not throw. It throws only for a signal the platform does not know,
596
- // which SIGINT/SIGTERM/SIGKILL never are.
597
- if (!isWin) child.kill(sig);
598
- if (escalation) return; // already counting down; further signals are noise
599
- escalation = setTimeout(() => {
600
- // Still here after its grace window. Stop waiting on it.
601
- child.kill("SIGKILL");
602
- process.exit(128 + (constants.signals[sig] ?? 15));
603
- }, ESCALATE_AFTER_MS);
604
- });
605
- }
606
-
607
- child.on("exit", (code, signal) => {
608
- if (escalation) clearTimeout(escalation);
609
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
610
- // conventional shell exit status rather than a bare 0.
611
- if (signal) {
612
- process.exit(128 + (constants.signals[signal] ?? 15));
613
- }
614
- process.exit(code ?? 0);
615
- });
616
- }
797
+ // their OAM_BIN is wrong or their oam is too old to use.
798
+ if (notes.length > 0) await errSync(`caddy-mcp: ${notes.join("; ")}; ${fallbackTail(hostOam)}.\n`);
799
+ await fallBack(hostOam).catch(fallbackFailed);
617
800
  }
618
801
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.4.3",
3
+ "version": "2.5.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "Caddy MCP server for Claude Code, Cursor, and any MCP client: admin API, config, routes, reverse proxy, TLS, PKI, metrics",
6
6
  "license": "MIT",