@yawlabs/caddy-mcp 2.5.10 → 2.6.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 +15 -0
- package/bin/caddy-mcp.mjs +126 -42
- package/dist/index.js +6 -1
- package/dist/server.js +6 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -104,6 +104,21 @@ that socket: over a unix path caddy-mcp is usually talking to Caddy's own
|
|
|
104
104
|
socket, where the token does nothing — and, before Caddy 2.11.3, is logged in
|
|
105
105
|
clear.
|
|
106
106
|
|
|
107
|
+
**Runtime (oam or Node):**
|
|
108
|
+
|
|
109
|
+
The `caddy-mcp` command prefers [oam](https://oamjs.org) when it finds a usable
|
|
110
|
+
one and otherwise runs on Node. It never serves on an oam older than 0.18.0.
|
|
111
|
+
|
|
112
|
+
| Environment variable | Default | Description |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `CADDY_MCP_RUNTIME` | `auto` | `auto`: the newest oam at 0.18.0 or newer, else Node. `oam`: oam or exit with an error. `node`: always Node -- the escape hatch when a problem only shows up under oam. |
|
|
115
|
+
| `CADDY_MCP_SANDBOX` | (unset) | `1` runs the server under oam's `--permission` sandbox: network limited to the `CADDY_ADMIN_URL` host and port, filesystem limited to `CADDY_MCP_SNAPSHOT_DIR`, no child processes. Needs oam; under `auto` a machine without one runs unsandboxed, so pair it with `CADDY_MCP_RUNTIME=oam` when the sandbox has to hold. A unix-socket `CADDY_ADMIN_URL` is not reachable under the sandbox yet. |
|
|
116
|
+
| `OAM_BIN` | (unset) | Path to the oam binary to use. Otherwise the launcher checks `OAM_INSTALL_DIR`, the installed locations (`~/.oam/bin`, and `%LOCALAPPDATA%\oam\bin` on Windows) and `PATH`, and takes the newest. |
|
|
117
|
+
|
|
118
|
+
An outdated oam is fixed with `oam self-update`. Under Yaw MCP, which runs servers
|
|
119
|
+
on oam by default, `yaw-mcp set <namespace> runtime=node` switches this server to
|
|
120
|
+
Node.
|
|
121
|
+
|
|
107
122
|
**Alternate MCP clients:**
|
|
108
123
|
|
|
109
124
|
| Client | Config file |
|
package/bin/caddy-mcp.mjs
CHANGED
|
@@ -88,18 +88,24 @@
|
|
|
88
88
|
*
|
|
89
89
|
* The admin API endpoint is DERIVED from CADDY_ADMIN_URL (default
|
|
90
90
|
* http://localhost:2019 -- byte-identical to DEFAULT_URL in src/api.ts, see
|
|
91
|
-
* sandboxFlags). For a TCP endpoint the grant is the
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
91
|
+
* sandboxFlags). For a TCP endpoint the grant is "host:port" -- the port taken
|
|
92
|
+
* from the URL, or the scheme's default (80/443) when it names none. Since oam
|
|
93
|
+
* 0.18.0, the floor, a port-scoped `--allow-net` entry admits `fetch` to that
|
|
94
|
+
* port exactly as it admits a socket, and the resource fetch presents is
|
|
95
|
+
* "host:port" (an IPv6 literal bracketed, "[::1]:2019"). Up to 0.17.1 a
|
|
96
|
+
* port-scoped entry admitted no HTTP request at all, which is why this grant
|
|
97
|
+
* used to be the bare host -- and the bare host admits every port on it.
|
|
97
98
|
*
|
|
98
99
|
* A unix-socket CADDY_ADMIN_URL gets NO net grant, which DENIES the category
|
|
99
100
|
* outright -- it is not an oversight that it looks narrower than the TCP case.
|
|
100
|
-
* oam
|
|
101
|
-
*
|
|
102
|
-
*
|
|
101
|
+
* oam 0.18.0 added net over pipes (Unix domain sockets, Windows named pipes)
|
|
102
|
+
* with `http.request({ socketPath })` riding on them, and under --permission a
|
|
103
|
+
* socket would need `--allow-net=<absolute path>` plus fs read and write grants
|
|
104
|
+
* naming it. That has not been measured for this server: oam's own changelog
|
|
105
|
+
* calls the Unix half untested off Windows, and this server has not been
|
|
106
|
+
* run that way on Linux/macOS. Until it is, the socket stays
|
|
107
|
+
* denied; the alternative was a bare `--allow-net`, which grants every host on
|
|
108
|
+
* the network. See sandboxFlags for the mechanism.
|
|
103
109
|
*
|
|
104
110
|
* Child-process stays denied: this server drives Caddy entirely over its admin
|
|
105
111
|
* HTTP API and never shells out to the `caddy` binary (the only execFileSync
|
|
@@ -184,7 +190,9 @@ function pathKey(p) {
|
|
|
184
190
|
* Both installed forms are checked on Windows: the installer defaults to
|
|
185
191
|
* %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
|
|
186
192
|
* OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
187
|
-
* install.
|
|
193
|
+
* install. OAM_INSTALL_DIR itself, when set, is checked FIRST: it is the
|
|
194
|
+
* installer's target directory (oam docs/cli-reference.md), so an oam installed
|
|
195
|
+
* there and never put on PATH would otherwise be invisible.
|
|
188
196
|
*
|
|
189
197
|
* PATH is resolved manually rather than by spawning `which`/`where`, which
|
|
190
198
|
* would cost a subprocess on every launch just to decide whether to spawn.
|
|
@@ -209,6 +217,7 @@ function discoverOamPaths() {
|
|
|
209
217
|
if (isWin) {
|
|
210
218
|
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
211
219
|
}
|
|
220
|
+
if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
|
|
212
221
|
const onPath = (process.env.PATH ?? "")
|
|
213
222
|
.split(delimiter)
|
|
214
223
|
.filter(Boolean)
|
|
@@ -317,7 +326,8 @@ function runtimePlan({ mode, hostOam, sandbox }) {
|
|
|
317
326
|
* not after it. `oam run --permission file.js` is rejected outright, which is a
|
|
318
327
|
* good failure but only because it is loud -- ordering here is load-bearing.
|
|
319
328
|
*
|
|
320
|
-
* Net grants
|
|
329
|
+
* Net grants (oam 0.18.0): an entry without a port admits every port on that
|
|
330
|
+
* host; "host:port" is exact, for sockets and HTTP requests alike.
|
|
321
331
|
* A denied environment variable is ABSENT from process.env rather than throwing,
|
|
322
332
|
* so the env list below is derived from what the bundle actually reads; trimming
|
|
323
333
|
* it produces silent misbehaviour, not a clear denial.
|
|
@@ -355,10 +365,14 @@ function sandboxFlags() {
|
|
|
355
365
|
// guards against, reached by a different route.
|
|
356
366
|
//
|
|
357
367
|
// Omitting the flag DENIES the category (oam reads an absent --allow-net as
|
|
358
|
-
// false, a bare one as "*")
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
368
|
+
// false, a bare one as "*"). Re-verified against oam 0.18.0 -- bare grant lets
|
|
369
|
+
// an unrelated host through, omitted grant denies it. What denial costs is
|
|
370
|
+
// NOT yet measured: oam 0.18.0 added Unix domain sockets, and api.ts's
|
|
371
|
+
// node:http `socketPath` dial rides on them, so a granted socket may now work
|
|
372
|
+
// there (it would need `--allow-net=<absolute path>` plus --allow-fs-read and
|
|
373
|
+
// --allow-fs-write naming the socket file). Upstream calls the Unix half
|
|
374
|
+
// untested off Windows, so the socket stays denied until it is run on
|
|
375
|
+
// Linux/macOS; an operator who needs it can run unsandboxed meanwhile.
|
|
362
376
|
//
|
|
363
377
|
// This mirrors getMalformedUnixUrl's predicate in src/api.ts, NOT the stricter
|
|
364
378
|
// getUnixSocketPath -- deliberately, and the difference is the whole point.
|
|
@@ -383,14 +397,13 @@ function sandboxFlags() {
|
|
|
383
397
|
if (!isUnixDsn) {
|
|
384
398
|
try {
|
|
385
399
|
const u = new URL(dsn);
|
|
386
|
-
// HOST
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
if (u.hostname) netFlag = `--allow-net=${u.hostname}`;
|
|
400
|
+
// HOST:PORT. Since oam 0.18.0 the resource `fetch` presents is "host:port"
|
|
401
|
+
// and a port-scoped entry admits it, so the grant names exactly the
|
|
402
|
+
// endpoint api.ts dials. `u.port` is "" when the URL names the scheme's
|
|
403
|
+
// default port, so fill that in: 443 for https, 80 otherwise (api.ts only
|
|
404
|
+
// dials http and https). `u.hostname` keeps an IPv6 literal's brackets,
|
|
405
|
+
// which is the form oam matches ("[::1]:2019"; "::1:2019" is refused).
|
|
406
|
+
if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || (u.protocol === "https:" ? "443" : "80")}`;
|
|
394
407
|
} catch {
|
|
395
408
|
// Genuinely unparseable CADDY_ADMIN_URL (not the unix forms -- those are
|
|
396
409
|
// handled above): leave the grant open. The server will fail on its own
|
|
@@ -422,20 +435,23 @@ function sandboxFlags() {
|
|
|
422
435
|
// quietly stop surviving a restart -- the thing the operator turned the
|
|
423
436
|
// variable on to get.
|
|
424
437
|
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
438
|
+
// ONE spelling is enough. Since oam 0.18.0 a relative --allow-fs-* entry is
|
|
439
|
+
// resolved against the cwd at startup, and each path a call passes is resolved
|
|
440
|
+
// against the cwd of the moment before it is matched, so "./snaps", "snaps"
|
|
441
|
+
// and "snaps/c.json" all land inside the same grant (snapshots.ts hands the
|
|
442
|
+
// raw variable to readdirSync/mkdirSync and builds per-file paths with
|
|
443
|
+
// path.join). Matching is path CONTAINMENT, not a string prefix: a grant for
|
|
444
|
+
// "/var/snap" does not admit "/var/snapshots-elsewhere" (measured on 0.18.0).
|
|
445
|
+
// On Windows the comparison follows node's path resolution with the `\\?\`
|
|
446
|
+
// prefix and is case-SENSITIVE, so spell the directory the way the server
|
|
447
|
+
// will.
|
|
430
448
|
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// escape, so a directory whose path contains a comma cannot be granted here.
|
|
449
|
+
// One consequence worth naming rather than discovering: oam splits the list
|
|
450
|
+
// on commas with no escape, so a directory whose path contains a comma cannot
|
|
451
|
+
// be granted here.
|
|
435
452
|
const snapshotDir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
|
|
436
453
|
if (snapshotDir) {
|
|
437
|
-
|
|
438
|
-
flags.push(`--allow-fs-read=${forms}`, `--allow-fs-write=${forms}`);
|
|
454
|
+
flags.push(`--allow-fs-read=${snapshotDir}`, `--allow-fs-write=${snapshotDir}`);
|
|
439
455
|
}
|
|
440
456
|
|
|
441
457
|
return flags;
|
|
@@ -510,20 +526,29 @@ function unusableReason(path, version, label = path) {
|
|
|
510
526
|
|
|
511
527
|
/**
|
|
512
528
|
* Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
|
|
513
|
-
* binary. Returns the choice (or null) plus stderr
|
|
514
|
-
*
|
|
515
|
-
* when
|
|
529
|
+
* binary. Returns the choice (or null) plus what stderr needs:
|
|
530
|
+
* overrideNote why OAM_BIN was passed over, or null
|
|
531
|
+
* skipped why each discovered binary was passed over, when none was chosen
|
|
532
|
+
* passedOver the `version` of every existing binary rejected (OAM_BIN
|
|
533
|
+
* included), so a hard failure can name the right remedy
|
|
534
|
+
* overrideMissing OAM_BIN was set to a path that does not exist
|
|
516
535
|
*/
|
|
517
536
|
function chooseOam() {
|
|
518
537
|
const override = process.env.OAM_BIN;
|
|
519
538
|
let overrideNote = null;
|
|
539
|
+
let overrideMissing = false;
|
|
540
|
+
const passedOver = [];
|
|
520
541
|
if (override) {
|
|
521
542
|
if (!existsSync(override)) {
|
|
522
543
|
overrideNote = `OAM_BIN=${override} does not exist`;
|
|
544
|
+
overrideMissing = true;
|
|
523
545
|
} else {
|
|
524
546
|
const version = oamVersion(override);
|
|
525
|
-
if (atLeast(version, OAM_MIN))
|
|
547
|
+
if (atLeast(version, OAM_MIN)) {
|
|
548
|
+
return { chosen: { path: override, version }, overrideNote, skipped: [], passedOver, overrideMissing };
|
|
549
|
+
}
|
|
526
550
|
overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
|
|
551
|
+
passedOver.push(version);
|
|
527
552
|
}
|
|
528
553
|
}
|
|
529
554
|
const overrideKey = override ? pathKey(override) : null;
|
|
@@ -532,7 +557,66 @@ function chooseOam() {
|
|
|
532
557
|
.map((path) => ({ path, version: oamVersion(path) }));
|
|
533
558
|
const chosen = pickNewest(candidates);
|
|
534
559
|
const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
|
|
535
|
-
|
|
560
|
+
if (!chosen) passedOver.push(...candidates.map((c) => c.version));
|
|
561
|
+
return { chosen, overrideNote, skipped, passedOver, overrideMissing };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* What would fix "no usable oam", one line per cause that was actually seen.
|
|
566
|
+
*
|
|
567
|
+
* An outdated oam is fixed by `oam self-update`; an unrunnable one is not (it
|
|
568
|
+
* never ran, so updating it in place cannot help); and only when NO oam was
|
|
569
|
+
* found is installing one the answer. Even that line is replaced where there is
|
|
570
|
+
* nothing to install: oam publishes darwin arm64/x64, windows arm64/x64 and
|
|
571
|
+
* linux x64, so on any other Linux arch (a Pi, an arm64 cloud instance, WSL on
|
|
572
|
+
* an ARM Windows host) "install oam" is an impossible remedy.
|
|
573
|
+
*/
|
|
574
|
+
function remedyFor({ passedOver, overrideMissing, shim }) {
|
|
575
|
+
const lines = [];
|
|
576
|
+
if (passedOver.some((v) => v !== null)) {
|
|
577
|
+
lines.push(`Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer.\n`);
|
|
578
|
+
}
|
|
579
|
+
if (passedOver.some((v) => v === null)) {
|
|
580
|
+
lines.push("Check that it is an executable oam binary for this platform.\n");
|
|
581
|
+
}
|
|
582
|
+
if (overrideMissing) lines.push("Point OAM_BIN at an existing oam binary, or unset it.\n");
|
|
583
|
+
if (lines.length === 0 && !shim) {
|
|
584
|
+
lines.push(
|
|
585
|
+
process.platform === "linux" && process.arch !== "x64"
|
|
586
|
+
? `oam publishes no build for linux-${process.arch}, so there is nothing to install here; set OAM_BIN=/path/to/oam if you built one yourself.\n`
|
|
587
|
+
: "Install oam from https://oamjs.org, or set OAM_BIN=/path/to/oam.\n",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
lines.push("Or use CADDY_MCP_RUNTIME=node to run on Node.\n");
|
|
591
|
+
return lines.join("");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The env a child is spawned with: process.env as-is on Node, and on an oam host
|
|
596
|
+
* a copy whose NODE_OPTIONS has every `--permission` / `--allow-*` token removed.
|
|
597
|
+
*
|
|
598
|
+
* An oam host can carry those in NODE_OPTIONS -- since 0.18.0 oam reads them
|
|
599
|
+
* from there, and appends its own execArgv's permission flags to every child's
|
|
600
|
+
* NODE_OPTIONS -- and handing them on breaks both children this launcher
|
|
601
|
+
* spawns. Node refuses an oam-only flag outright: `NODE_OPTIONS=--allow-net node`
|
|
602
|
+
* exits 9 with "--allow-net is not allowed in NODE_OPTIONS" (Node 22), so the
|
|
603
|
+
* Node handoff would die before the server loads. A fresh oam would instead
|
|
604
|
+
* inherit the HOST's grants rather than the ones sandboxFlags derived for it.
|
|
605
|
+
* The child's sandbox, if any, is decided by its own argv and nothing else.
|
|
606
|
+
*
|
|
607
|
+
* Tokens are split on whitespace, as Node splits NODE_OPTIONS; a value-taking
|
|
608
|
+
* permission flag is always written `--allow-x=value`, one token. An emptied
|
|
609
|
+
* NODE_OPTIONS is removed rather than left as "".
|
|
610
|
+
*/
|
|
611
|
+
function childEnv() {
|
|
612
|
+
if (process.versions.oam === undefined) return process.env;
|
|
613
|
+
const raw = process.env.NODE_OPTIONS;
|
|
614
|
+
if (!raw) return process.env;
|
|
615
|
+
const kept = raw.split(/\s+/).filter((token) => token && token !== "--permission" && !token.startsWith("--allow-"));
|
|
616
|
+
const env = { ...process.env };
|
|
617
|
+
if (kept.length > 0) env.NODE_OPTIONS = kept.join(" ");
|
|
618
|
+
else delete env.NODE_OPTIONS;
|
|
619
|
+
return env;
|
|
536
620
|
}
|
|
537
621
|
|
|
538
622
|
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
@@ -581,7 +665,7 @@ async function launchChild(cmd, args, onLaunchFailed) {
|
|
|
581
665
|
// server's shutdown path. Piping preserves both as well: bytes are copied
|
|
582
666
|
// unchanged, and stdin's end propagates to the child.
|
|
583
667
|
stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
584
|
-
env:
|
|
668
|
+
env: childEnv(),
|
|
585
669
|
windowsHide: true,
|
|
586
670
|
});
|
|
587
671
|
} catch (err) {
|
|
@@ -752,7 +836,7 @@ if (plan === "in-process") {
|
|
|
752
836
|
const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
|
|
753
837
|
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
|
|
754
838
|
} else {
|
|
755
|
-
const { chosen, overrideNote, skipped } = chooseOam();
|
|
839
|
+
const { chosen, overrideNote, skipped, passedOver, overrideMissing } = chooseOam();
|
|
756
840
|
|
|
757
841
|
if (chosen) {
|
|
758
842
|
if (overrideNote)
|
|
@@ -789,7 +873,7 @@ if (plan === "in-process") {
|
|
|
789
873
|
await errSync(
|
|
790
874
|
`caddy-mcp: CADDY_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
|
|
791
875
|
notes.map((note) => ` ${note}\n`).join("") +
|
|
792
|
-
|
|
876
|
+
remedyFor({ passedOver, overrideMissing, shim }),
|
|
793
877
|
);
|
|
794
878
|
process.exit(1);
|
|
795
879
|
}
|
package/dist/index.js
CHANGED
|
@@ -304,7 +304,12 @@ function originOf(origin) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
function settleAdminRestart(origin) {
|
|
307
|
-
if (!origin
|
|
307
|
+
if (!origin) return Promise.resolve();
|
|
308
|
+
if (!dispatcherHooked) {
|
|
309
|
+
if (process.versions.oam === void 0) return Promise.resolve();
|
|
310
|
+
return new Promise((resolve) => setTimeout(resolve, ADMIN_RESTART_SETTLE_MS));
|
|
311
|
+
}
|
|
312
|
+
if ((liveSockets.get(origin) ?? 0) === 0) return Promise.resolve();
|
|
308
313
|
return new Promise((resolve) => {
|
|
309
314
|
const timer = setTimeout(done, ADMIN_RESTART_SETTLE_MS);
|
|
310
315
|
function check() {
|
package/dist/server.js
CHANGED
|
@@ -302,7 +302,12 @@ function originOf(origin) {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
function settleAdminRestart(origin) {
|
|
305
|
-
if (!origin
|
|
305
|
+
if (!origin) return Promise.resolve();
|
|
306
|
+
if (!dispatcherHooked) {
|
|
307
|
+
if (process.versions.oam === void 0) return Promise.resolve();
|
|
308
|
+
return new Promise((resolve) => setTimeout(resolve, ADMIN_RESTART_SETTLE_MS));
|
|
309
|
+
}
|
|
310
|
+
if ((liveSockets.get(origin) ?? 0) === 0) return Promise.resolve();
|
|
306
311
|
return new Promise((resolve) => {
|
|
307
312
|
const timer = setTimeout(done, ADMIN_RESTART_SETTLE_MS);
|
|
308
313
|
function check() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"lint:fix": "node scripts/lint.mjs check --write src/ bin/ scripts/",
|
|
33
33
|
"typecheck": "node scripts/typecheck.mjs",
|
|
34
34
|
"typecheck:tsc": "tsc --noEmit",
|
|
35
|
+
"check:oam-floor": "node scripts/check-oam-floor.mjs",
|
|
35
36
|
"test:ci": "npm run build && npm test",
|
|
36
37
|
"prepublishOnly": "npm run build",
|
|
37
38
|
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
|