@yawlabs/caddy-mcp 2.0.0 → 2.2.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.
Files changed (2) hide show
  1. package/bin/caddy-mcp.mjs +269 -0
  2. package/package.json +3 -2
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/caddy-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file.
7
+ *
8
+ * Unlike npmjs-mcp, this server is NOT a zero-dependency bundle -- dist/
9
+ * imports @modelcontextprotocol/sdk and zod from node_modules at runtime. That
10
+ * is fine on both paths: oam does npm resolution against an existing
11
+ * node_modules with CommonJS interop, and it was verified here before this
12
+ * launcher was written (`oam run dist/index.js -- --version` prints the same
13
+ * version Node does).
14
+ *
15
+ * WHY THE FALLBACK COSTS NOTHING
16
+ * npm has already started Node to run this launcher, so falling back is a
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.
20
+ *
21
+ * 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
24
+ * 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
+ *
28
+ * For an MCP host config, point straight at oam and skip this file:
29
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
30
+ *
31
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
32
+ * `CADDY_MCP_SANDBOX=1` runs the server under oam's permission model.
33
+ *
34
+ * The admin API endpoint is DERIVED from CADDY_ADMIN_URL (default
35
+ * http://127.0.0.1:2019), host and port both pinned -- grants are prefix-matched,
36
+ * so a bare host would also admit every other port on it. Filesystem AND
37
+ * child-process both stay denied: this server drives Caddy entirely over its
38
+ * admin HTTP API and never shells out to the `caddy` binary (the only
39
+ * execFileSync calls in the repo are in src/tests/).
40
+ *
41
+ * Opt-in, not default: a denied environment variable is ABSENT from process.env
42
+ * rather than throwing, so an under-granted CADDY_API_TOKEN reads as
43
+ * "unauthenticated". The env list is derived from the shipped bundle.
44
+ *
45
+ * MINIMUM OAM VERSION
46
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
47
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
48
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
49
+ * behaved as `'pipe'`. This server spawns nothing, so the floor is
50
+ * enforced for consistency across @yawlabs/*-mcp rather than because this
51
+ * launcher was exposed.
52
+ * An older oam is not an error: the launcher falls back to Node and says so on
53
+ * stderr. Pinning the floor here is what makes that fallback automatic.
54
+ *
55
+ * SELECTION
56
+ * CADDY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
57
+ * CADDY_MCP_RUNTIME=node never use oam
58
+ * CADDY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
59
+ * CADDY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
60
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
61
+ */
62
+
63
+ import { execFileSync, spawn } from "node:child_process";
64
+ import { existsSync } from "node:fs";
65
+ import { constants, homedir } from "node:os";
66
+ import { delimiter, join } from "node:path";
67
+ import { fileURLToPath } from "node:url";
68
+
69
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
70
+ const OAM_MIN = [0, 9, 0];
71
+
72
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
73
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
74
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
75
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
76
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
77
+ const isWin = process.platform === "win32";
78
+ const exe = isWin ? "oam.exe" : "oam";
79
+
80
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
81
+ function findOam() {
82
+ // 1. Explicit override wins and is never second-guessed.
83
+ const override = process.env.OAM_BIN;
84
+ if (override) return existsSync(override) ? override : null;
85
+
86
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
87
+ // usually has oam/target/release on PATH, and a build directory is the
88
+ // wrong thing for a user-facing launcher to bind to: cargo replaces the
89
+ // binary underneath running processes, and the dev build is not the
90
+ // release the user installed. Preferring the installed copy makes the
91
+ // default path "what a normal user has", and OAM_BIN remains the way to
92
+ // point deliberately at a dev build.
93
+ //
94
+ // Both forms are checked on Windows: the installer defaults to
95
+ // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
96
+ // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
97
+ // install.
98
+ const installed = [join(homedir(), ".oam", "bin", exe)];
99
+ if (isWin) {
100
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
101
+ }
102
+ for (const candidate of installed) {
103
+ if (existsSync(candidate)) return candidate;
104
+ }
105
+
106
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
107
+ // would cost a subprocess on every launch just to decide whether to spawn.
108
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
109
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
110
+ if (!dir) continue;
111
+ for (const ext of isWin ? pathExt : [""]) {
112
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
113
+ if (existsSync(candidate)) return candidate;
114
+ }
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
122
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
123
+ */
124
+ function oamVersion(cmd) {
125
+ try {
126
+ const out = execFileSync(cmd, ["--version"], {
127
+ encoding: "utf-8",
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ });
130
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
131
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
132
+ } catch {
133
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
139
+ function atLeast(v, min) {
140
+ if (!v) return false;
141
+ for (let i = 0; i < min.length; i++) {
142
+ if (v[i] > min[i]) return true;
143
+ if (v[i] < min[i]) return false;
144
+ }
145
+ return true;
146
+ }
147
+
148
+ /**
149
+ * The `--permission` grant list, or [] when the sandbox is not requested.
150
+ *
151
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
152
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
153
+ * good failure but only because it is loud -- ordering here is load-bearing.
154
+ *
155
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
156
+ * A denied environment variable is ABSENT from process.env rather than throwing,
157
+ * so the env list below is derived from what the bundle actually reads; trimming
158
+ * it produces silent misbehaviour, not a clear denial.
159
+ */
160
+ function sandboxFlags() {
161
+ if (process.env.CADDY_MCP_SANDBOX !== "1") return [];
162
+
163
+ // Derived, not hardcoded: the only endpoint this server may reach is the one
164
+ // it was configured to reach. Grants are prefix-matched against "host:port"
165
+ // for sockets, so host alone would also admit any other port on that host --
166
+ // pin both. A DSN we cannot parse falls back to a bare grant rather than a
167
+ // broken one, because a wrong narrow grant fails at connect time.
168
+ const dsn = process.env.CADDY_ADMIN_URL ?? "http://127.0.0.1:2019";
169
+ let netFlag = "--allow-net";
170
+ if (dsn) {
171
+ try {
172
+ const u = new URL(dsn);
173
+ if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || 2019}`;
174
+ } catch {
175
+ // Unparseable CADDY_ADMIN_URL: leave the grant open. The server will fail on
176
+ // its own connection error, which names the real problem.
177
+ }
178
+ }
179
+
180
+ const env = ["CADDY_ADMIN_URL","CADDY_API_TOKEN","CADDY_LOAD_TIMEOUT","CADDY_MAX_RETRIES","CADDY_TIMEOUT"];
181
+
182
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
183
+ return flags;
184
+ }
185
+
186
+ /** Run the server in THIS process. The zero-overhead fallback. */
187
+ async function runInProcess() {
188
+ // A server may gate its bootstrap on being the process ENTRY POINT --
189
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
190
+ // test file can import the module for unit tests without connecting a stdio
191
+ // transport. aws-mcp does exactly this. Importing the server here would leave
192
+ // argv[1] pointing at THIS launcher, the guard would read false, and the
193
+ // server would load but never serve: the MCP handshake just hangs.
194
+ //
195
+ // Point argv[1] at the server first, so the in-process path is
196
+ // indistinguishable from having executed the file directly. The spawn path
197
+ // needs no equivalent -- there argv[1] is already the server.
198
+ process.argv[1] = SERVER_ENTRY;
199
+ await import(SERVER_URL.href);
200
+ }
201
+
202
+ const mode = (process.env.CADDY_MCP_RUNTIME ?? "auto").toLowerCase();
203
+
204
+ if (mode === "node") {
205
+ await runInProcess();
206
+ } else {
207
+ const oam = findOam();
208
+
209
+ if (!oam) {
210
+ if (mode === "oam") {
211
+ // Explicitly demanded, so this is a real misconfiguration. writeSync
212
+ // because stderr is async for TTYs/pipes on Windows and process.exit
213
+ // truncates pending writes.
214
+ const { writeSync } = await import("node:fs");
215
+ writeSync(
216
+ 2,
217
+ "caddy-mcp: CADDY_MCP_RUNTIME=oam but no oam binary was found.\n" +
218
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
219
+ );
220
+ process.exit(1);
221
+ }
222
+ await runInProcess();
223
+ } else {
224
+ // `--` separates oam's own flags from the script's argv, so `caddy-mcp
225
+ // --version` and any host-supplied flags survive the hop unchanged.
226
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
227
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
228
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
229
+ // server's shutdown path.
230
+ stdio: "inherit",
231
+ env: process.env,
232
+ windowsHide: true,
233
+ });
234
+
235
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
236
+ // wrong arch, permission), fall back rather than failing the whole server.
237
+ // `spawned` prevents falling back AFTER the child started, which would
238
+ // double-start the server on the same stdio.
239
+ let spawned = false;
240
+ child.on("spawn", () => {
241
+ spawned = true;
242
+ });
243
+ child.on("error", (err) => {
244
+ if (spawned) return;
245
+ if (mode === "oam") {
246
+ process.stderr.write(`caddy-mcp: failed to launch oam (${err.message})\n`);
247
+ process.exit(1);
248
+ }
249
+ void runInProcess();
250
+ });
251
+
252
+ // Forward termination so the server's own shutdown path runs in the child
253
+ // rather than the child being orphaned. No-op on Windows, harmless to add.
254
+ for (const sig of ["SIGINT", "SIGTERM"]) {
255
+ process.on(sig, () => {
256
+ if (!child.killed) child.kill(sig);
257
+ });
258
+ }
259
+
260
+ child.on("exit", (code, signal) => {
261
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
262
+ // conventional shell exit status rather than a bare 0.
263
+ if (signal) {
264
+ process.exit(128 + (constants.signals[signal] ?? 15));
265
+ }
266
+ process.exit(code ?? 0);
267
+ });
268
+ }
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "MCP server for managing Caddy web servers via the admin API",
6
6
  "license": "MIT",
@@ -15,9 +15,10 @@
15
15
  "main": "./dist/server.js",
16
16
  "types": "./dist/server.d.ts",
17
17
  "bin": {
18
- "caddy-mcp": "dist/index.js"
18
+ "caddy-mcp": "bin/caddy-mcp.mjs"
19
19
  },
20
20
  "files": [
21
+ "bin/caddy-mcp.mjs",
21
22
  "dist",
22
23
  "!dist/**/*.test.*",
23
24
  "README.md",