@yawlabs/caddy-mcp 2.4.3 → 2.4.4
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/bin/caddy-mcp.mjs +456 -273
- package/package.json +1 -1
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
|
|
6
|
-
*
|
|
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.
|
|
19
|
-
* stat-only
|
|
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
|
|
23
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* apply
|
|
45
|
-
* word
|
|
46
|
-
*
|
|
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,
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* pair it with CADDY_MCP_SANDBOX=1 when
|
|
56
|
-
* CADDY_MCP_RUNTIME=node ignores the sandbox entirely.
|
|
57
|
-
*
|
|
58
|
-
* THE `--permission` SANDBOX (
|
|
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.
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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=
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* CADDY_MCP_RUNTIME=
|
|
104
|
-
*
|
|
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
|
|
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
|
|
116
|
-
const OAM_MIN = [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
|
-
/**
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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"
|
|
218
|
-
* "discover"
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
472
|
-
if (
|
|
473
|
-
await
|
|
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
|
+
"version": "2.4.4",
|
|
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",
|