@yawlabs/caddy-mcp 1.3.1 → 2.1.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/bin/caddy-mcp.mjs +175 -0
- package/dist/api.d.ts +22 -0
- package/dist/format.d.ts +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +94 -18
- package/dist/resources.d.ts +2 -0
- package/dist/server.d.ts +4 -7
- package/dist/server.js +94 -18
- package/dist/snapshots.d.ts +9 -0
- package/dist/tools/adapt.d.ts +2 -0
- package/dist/tools/config.d.ts +2 -0
- package/dist/tools/operational.d.ts +19 -0
- package/dist/tools/routes.d.ts +7 -0
- package/dist/tools/tls.d.ts +2 -0
- package/package.json +7 -5
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
* SELECTION
|
|
32
|
+
* CADDY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
33
|
+
* CADDY_MCP_RUNTIME=node never use oam
|
|
34
|
+
* CADDY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
35
|
+
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { constants, homedir } from "node:os";
|
|
41
|
+
import { delimiter, join } from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
|
|
44
|
+
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
45
|
+
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
46
|
+
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
47
|
+
const SERVER_URL = new URL("../dist/index.js", import.meta.url);
|
|
48
|
+
const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
49
|
+
const isWin = process.platform === "win32";
|
|
50
|
+
const exe = isWin ? "oam.exe" : "oam";
|
|
51
|
+
|
|
52
|
+
/** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
|
|
53
|
+
function findOam() {
|
|
54
|
+
// 1. Explicit override wins and is never second-guessed.
|
|
55
|
+
const override = process.env.OAM_BIN;
|
|
56
|
+
if (override) return existsSync(override) ? override : null;
|
|
57
|
+
|
|
58
|
+
// 2. Installed locations, BEFORE PATH. Someone who develops oam itself
|
|
59
|
+
// usually has oam/target/release on PATH, and a build directory is the
|
|
60
|
+
// wrong thing for a user-facing launcher to bind to: cargo replaces the
|
|
61
|
+
// binary underneath running processes, and the dev build is not the
|
|
62
|
+
// release the user installed. Preferring the installed copy makes the
|
|
63
|
+
// default path "what a normal user has", and OAM_BIN remains the way to
|
|
64
|
+
// point deliberately at a dev build.
|
|
65
|
+
//
|
|
66
|
+
// Both forms are checked on Windows: the installer defaults to
|
|
67
|
+
// %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
|
|
68
|
+
// OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
69
|
+
// install.
|
|
70
|
+
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
71
|
+
if (isWin) {
|
|
72
|
+
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
73
|
+
}
|
|
74
|
+
for (const candidate of installed) {
|
|
75
|
+
if (existsSync(candidate)) return candidate;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
79
|
+
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
80
|
+
const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
|
|
81
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
82
|
+
if (!dir) continue;
|
|
83
|
+
for (const ext of isWin ? pathExt : [""]) {
|
|
84
|
+
const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
|
|
85
|
+
if (existsSync(candidate)) return candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
93
|
+
async function runInProcess() {
|
|
94
|
+
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
95
|
+
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
96
|
+
// test file can import the module for unit tests without connecting a stdio
|
|
97
|
+
// transport. aws-mcp does exactly this. Importing the server here would leave
|
|
98
|
+
// argv[1] pointing at THIS launcher, the guard would read false, and the
|
|
99
|
+
// server would load but never serve: the MCP handshake just hangs.
|
|
100
|
+
//
|
|
101
|
+
// Point argv[1] at the server first, so the in-process path is
|
|
102
|
+
// indistinguishable from having executed the file directly. The spawn path
|
|
103
|
+
// needs no equivalent -- there argv[1] is already the server.
|
|
104
|
+
process.argv[1] = SERVER_ENTRY;
|
|
105
|
+
await import(SERVER_URL.href);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const mode = (process.env.CADDY_MCP_RUNTIME ?? "auto").toLowerCase();
|
|
109
|
+
|
|
110
|
+
if (mode === "node") {
|
|
111
|
+
await runInProcess();
|
|
112
|
+
} else {
|
|
113
|
+
const oam = findOam();
|
|
114
|
+
|
|
115
|
+
if (!oam) {
|
|
116
|
+
if (mode === "oam") {
|
|
117
|
+
// Explicitly demanded, so this is a real misconfiguration. writeSync
|
|
118
|
+
// because stderr is async for TTYs/pipes on Windows and process.exit
|
|
119
|
+
// truncates pending writes.
|
|
120
|
+
const { writeSync } = await import("node:fs");
|
|
121
|
+
writeSync(
|
|
122
|
+
2,
|
|
123
|
+
"caddy-mcp: CADDY_MCP_RUNTIME=oam but no oam binary was found.\n" +
|
|
124
|
+
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
|
|
125
|
+
);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
await runInProcess();
|
|
129
|
+
} else {
|
|
130
|
+
// `--` separates oam's own flags from the script's argv, so `caddy-mcp
|
|
131
|
+
// --version` and any host-supplied flags survive the hop unchanged.
|
|
132
|
+
const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
133
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
134
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
135
|
+
// server's shutdown path.
|
|
136
|
+
stdio: "inherit",
|
|
137
|
+
env: process.env,
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
142
|
+
// wrong arch, permission), fall back rather than failing the whole server.
|
|
143
|
+
// `spawned` prevents falling back AFTER the child started, which would
|
|
144
|
+
// double-start the server on the same stdio.
|
|
145
|
+
let spawned = false;
|
|
146
|
+
child.on("spawn", () => {
|
|
147
|
+
spawned = true;
|
|
148
|
+
});
|
|
149
|
+
child.on("error", (err) => {
|
|
150
|
+
if (spawned) return;
|
|
151
|
+
if (mode === "oam") {
|
|
152
|
+
process.stderr.write(`caddy-mcp: failed to launch oam (${err.message})\n`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
void runInProcess();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
159
|
+
// rather than the child being orphaned. No-op on Windows, harmless to add.
|
|
160
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
161
|
+
process.on(sig, () => {
|
|
162
|
+
if (!child.killed) child.kill(sig);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
child.on("exit", (code, signal) => {
|
|
167
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
168
|
+
// conventional shell exit status rather than a bare 0.
|
|
169
|
+
if (signal) {
|
|
170
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
171
|
+
}
|
|
172
|
+
process.exit(code ?? 0);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ApiResponse<T = any> {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
status: number;
|
|
4
|
+
data?: T;
|
|
5
|
+
error?: string;
|
|
6
|
+
etag?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function configGet<T = any>(path?: string): Promise<ApiResponse<T>>;
|
|
9
|
+
export declare function configPost<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
|
|
10
|
+
export declare function configPut<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
|
|
11
|
+
export declare function configPatch<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
|
|
12
|
+
export declare function configDelete<T = any>(path: string): Promise<ApiResponse<T>>;
|
|
13
|
+
export declare function loadConfig(config: unknown, contentType?: string): Promise<ApiResponse>;
|
|
14
|
+
export declare function adapt<T = any>(config: string, adapter?: string): Promise<ApiResponse<T>>;
|
|
15
|
+
export declare function stop(): Promise<ApiResponse>;
|
|
16
|
+
export declare function getUpstreams(): Promise<ApiResponse>;
|
|
17
|
+
export declare function getPki(ca?: string): Promise<ApiResponse>;
|
|
18
|
+
export declare function getPkiCertificates(ca?: string): Promise<ApiResponse>;
|
|
19
|
+
export declare function configByIdGet<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
|
|
20
|
+
export declare function configByIdSet<T = any>(id: string, value: unknown, method?: "POST" | "PATCH" | "PUT", subpath?: string): Promise<ApiResponse<T>>;
|
|
21
|
+
export declare function configByIdDelete<T = any>(id: string, subpath?: string): Promise<ApiResponse<T>>;
|
|
22
|
+
export declare function getMetrics(): Promise<ApiResponse>;
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ApiResponse } from "./api.js";
|
|
2
|
+
/** Convert an API response to MCP tool result format */
|
|
3
|
+
export declare function formatResult(res: ApiResponse): {
|
|
4
|
+
isError: boolean;
|
|
5
|
+
content: {
|
|
6
|
+
type: "text";
|
|
7
|
+
text: string;
|
|
8
|
+
}[];
|
|
9
|
+
} | {
|
|
10
|
+
isError?: undefined;
|
|
11
|
+
content: {
|
|
12
|
+
type: "text";
|
|
13
|
+
text: string;
|
|
14
|
+
}[];
|
|
15
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -21,11 +21,15 @@ function setEtag(path, etag) {
|
|
|
21
21
|
}
|
|
22
22
|
etagCache.set(path, etag);
|
|
23
23
|
}
|
|
24
|
+
function isAncestorOf(ancestor, descendant) {
|
|
25
|
+
const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
|
|
26
|
+
return descendant.startsWith(`${base}/`);
|
|
27
|
+
}
|
|
24
28
|
function invalidateRelated(path) {
|
|
25
29
|
etagCache.delete(path);
|
|
26
30
|
for (const key of Array.from(etagCache.keys())) {
|
|
27
31
|
if (key === path) continue;
|
|
28
|
-
if (
|
|
32
|
+
if (isAncestorOf(key, path) || isAncestorOf(path, key)) {
|
|
29
33
|
etagCache.delete(key);
|
|
30
34
|
}
|
|
31
35
|
}
|
|
@@ -54,11 +58,20 @@ function getMaxRetries() {
|
|
|
54
58
|
}
|
|
55
59
|
return Math.min(floored, RETRY_HARD_CAP);
|
|
56
60
|
}
|
|
61
|
+
function getAdminOrigin() {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(getBaseUrl()).origin;
|
|
64
|
+
} catch {
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
57
68
|
function getHeaders(contentType) {
|
|
58
69
|
const headers = {};
|
|
59
70
|
if (contentType) headers["Content-Type"] = contentType;
|
|
60
71
|
const token = process.env.CADDY_API_TOKEN;
|
|
61
72
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
73
|
+
const origin = getAdminOrigin();
|
|
74
|
+
if (origin) headers.Origin = origin;
|
|
62
75
|
return headers;
|
|
63
76
|
}
|
|
64
77
|
function normalizePath(path) {
|
|
@@ -83,7 +96,9 @@ function isTransientFailure(res) {
|
|
|
83
96
|
if (res.status >= 500 && res.status <= 599) return true;
|
|
84
97
|
return false;
|
|
85
98
|
}
|
|
99
|
+
var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
|
|
86
100
|
function isRetryableMethod(method, path) {
|
|
101
|
+
if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
|
|
87
102
|
if (method !== "POST") return true;
|
|
88
103
|
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
89
104
|
}
|
|
@@ -142,6 +157,13 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
142
157
|
const hint = res.status === 401 || res.status === 403 ? " -- check CADDY_API_TOKEN" : "";
|
|
143
158
|
return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
|
|
144
159
|
}
|
|
160
|
+
if (res.status === 403 && /origin/i.test(text)) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
status: 403,
|
|
164
|
+
error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`
|
|
165
|
+
};
|
|
166
|
+
}
|
|
145
167
|
return { ok: false, status: res.status, error: text };
|
|
146
168
|
}
|
|
147
169
|
if (!text) return { ok: true, status: res.status, etag };
|
|
@@ -289,7 +311,8 @@ function formatResult(res) {
|
|
|
289
311
|
|
|
290
312
|
// src/tools/operational.ts
|
|
291
313
|
var HTTPS_PORT_RE = /:443(?:\D|$)/;
|
|
292
|
-
function describeServer(
|
|
314
|
+
function describeServer(rawValue) {
|
|
315
|
+
const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
|
293
316
|
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
294
317
|
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
295
318
|
const hasExplicitTls = !!raw.tls_connection_policies;
|
|
@@ -411,7 +434,7 @@ ${lines.join("\n")}` }]
|
|
|
411
434
|
);
|
|
412
435
|
server.tool(
|
|
413
436
|
"caddy_metrics",
|
|
414
|
-
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines
|
|
437
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines, keeping only '# HELP'/'# TYPE' lines for matching metrics; the '# EOF' end-of-file marker is always preserved. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
415
438
|
{
|
|
416
439
|
filter: z.string().optional().describe(
|
|
417
440
|
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
@@ -639,13 +662,25 @@ function registerConfigTools(server) {
|
|
|
639
662
|
);
|
|
640
663
|
server.tool(
|
|
641
664
|
"caddy_load",
|
|
642
|
-
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning.",
|
|
665
|
+
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
|
|
643
666
|
{
|
|
644
667
|
config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
645
|
-
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
|
|
668
|
+
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
|
|
669
|
+
confirm: z3.boolean().optional().default(false).describe("Must be true to replace the running configuration (safety)")
|
|
646
670
|
},
|
|
647
671
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
648
|
-
async ({ config, format }) => {
|
|
672
|
+
async ({ config, format, confirm }) => {
|
|
673
|
+
if (!confirm) {
|
|
674
|
+
return {
|
|
675
|
+
isError: true,
|
|
676
|
+
content: [
|
|
677
|
+
{
|
|
678
|
+
type: "text",
|
|
679
|
+
text: "Refusing to replace the running configuration without confirm=true. caddy_load discards every server and route not present in the supplied config. Re-run with confirm:true to proceed (the prior config is snapshotted and restorable via caddy_revert)."
|
|
680
|
+
}
|
|
681
|
+
]
|
|
682
|
+
};
|
|
683
|
+
}
|
|
649
684
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
650
685
|
const current = await configGet();
|
|
651
686
|
const res = await loadConfig(config, contentType);
|
|
@@ -782,6 +817,23 @@ ${lines.join("\n")}` }] };
|
|
|
782
817
|
|
|
783
818
|
// src/tools/routes.ts
|
|
784
819
|
import { z as z4 } from "zod";
|
|
820
|
+
var ROUTES_JSON_MAX_CHARS = 2e4;
|
|
821
|
+
var ROUTES_SUMMARY_MAX = 500;
|
|
822
|
+
function serializeRoutesCapped(routes) {
|
|
823
|
+
const parts = [];
|
|
824
|
+
let used = 2;
|
|
825
|
+
for (const route of routes) {
|
|
826
|
+
const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
|
|
827
|
+
const cost = entry.length + (parts.length > 0 ? 2 : 1);
|
|
828
|
+
if (parts.length > 0 && used + cost > ROUTES_JSON_MAX_CHARS) break;
|
|
829
|
+
parts.push(entry);
|
|
830
|
+
used += cost;
|
|
831
|
+
}
|
|
832
|
+
if (parts.length === 0) return { json: "[]", shown: 0 };
|
|
833
|
+
return { json: `[
|
|
834
|
+
${parts.join(",\n")}
|
|
835
|
+
]`, shown: parts.length };
|
|
836
|
+
}
|
|
785
837
|
function safeJoin(value) {
|
|
786
838
|
if (!Array.isArray(value)) return "";
|
|
787
839
|
return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
|
|
@@ -794,6 +846,7 @@ function stripPort(host) {
|
|
|
794
846
|
}
|
|
795
847
|
const colonIdx = host.lastIndexOf(":");
|
|
796
848
|
if (colonIdx === -1) return host;
|
|
849
|
+
if (host.indexOf(":") !== colonIdx) return host;
|
|
797
850
|
const portCandidate = host.substring(colonIdx + 1);
|
|
798
851
|
if (portCandidate.length === 0 || !/^\d+$/.test(portCandidate)) return host;
|
|
799
852
|
return host.substring(0, colonIdx);
|
|
@@ -847,9 +900,9 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
847
900
|
function registerRouteTools(server) {
|
|
848
901
|
server.tool(
|
|
849
902
|
"caddy_reverse_proxy",
|
|
850
|
-
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via
|
|
903
|
+
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
|
|
851
904
|
{
|
|
852
|
-
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
905
|
+
from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
853
906
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
854
907
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
855
908
|
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
|
|
@@ -858,7 +911,18 @@ function registerRouteTools(server) {
|
|
|
858
911
|
},
|
|
859
912
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
860
913
|
async ({ from, to, server: srv, id }) => {
|
|
861
|
-
const match = parseFrom(from);
|
|
914
|
+
const match = parseFrom(from.trim());
|
|
915
|
+
if (match.host?.[0]?.trim() === "" || match.host === void 0 && match.path === void 0) {
|
|
916
|
+
return {
|
|
917
|
+
isError: true,
|
|
918
|
+
content: [
|
|
919
|
+
{
|
|
920
|
+
type: "text",
|
|
921
|
+
text: `Error: "from" value ${JSON.stringify(from)} has no host or path to match on. Supply a domain ('api.local'), a path ('/api/*'), or both ('app.local/ws').`
|
|
922
|
+
}
|
|
923
|
+
]
|
|
924
|
+
};
|
|
925
|
+
}
|
|
862
926
|
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
863
927
|
const route = {
|
|
864
928
|
match: [match],
|
|
@@ -885,7 +949,7 @@ function registerRouteTools(server) {
|
|
|
885
949
|
]
|
|
886
950
|
};
|
|
887
951
|
}
|
|
888
|
-
const putRes = await configByIdSet(id, route, "
|
|
952
|
+
const putRes = await configByIdSet(id, route, "PATCH");
|
|
889
953
|
if (putRes.ok) {
|
|
890
954
|
return {
|
|
891
955
|
content: [
|
|
@@ -948,7 +1012,7 @@ function registerRouteTools(server) {
|
|
|
948
1012
|
);
|
|
949
1013
|
server.tool(
|
|
950
1014
|
"caddy_list_routes",
|
|
951
|
-
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
1015
|
+
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers, followed by the raw route JSON. Both halves are capped on large servers: the summary at 500 routes, the JSON at 20000 characters (truncated on whole-route boundaries, so it always parses). When either cap trims output, a note says how many routes were omitted -- read the rest with caddy_config_get at 'apps/http/servers/<server>/routes'.",
|
|
952
1016
|
{
|
|
953
1017
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
954
1018
|
},
|
|
@@ -971,7 +1035,8 @@ function registerRouteTools(server) {
|
|
|
971
1035
|
};
|
|
972
1036
|
}
|
|
973
1037
|
const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
|
|
974
|
-
|
|
1038
|
+
const summarized = Math.min(routes.length, ROUTES_SUMMARY_MAX);
|
|
1039
|
+
for (let i = 0; i < summarized; i++) {
|
|
975
1040
|
const rawRoute = routes[i];
|
|
976
1041
|
if (!rawRoute || typeof rawRoute !== "object") {
|
|
977
1042
|
lines.push(` Route ${i}: <invalid>`);
|
|
@@ -1070,12 +1135,23 @@ function registerRouteTools(server) {
|
|
|
1070
1135
|
const terminal = route.terminal === true ? " [terminal]" : "";
|
|
1071
1136
|
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
|
|
1072
1137
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
{
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
};
|
|
1138
|
+
if (summarized < routes.length) {
|
|
1139
|
+
lines.push(
|
|
1140
|
+
` ... ${routes.length - summarized} more route(s) not shown (summary caps at ${ROUTES_SUMMARY_MAX}).`
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
const { json, shown } = serializeRoutesCapped(routes);
|
|
1144
|
+
const content = [
|
|
1145
|
+
{ type: "text", text: lines.join("\n") },
|
|
1146
|
+
{ type: "text", text: json }
|
|
1147
|
+
];
|
|
1148
|
+
if (shown < routes.length) {
|
|
1149
|
+
content.push({
|
|
1150
|
+
type: "text",
|
|
1151
|
+
text: `[JSON block truncated: showing ${shown} of ${routes.length} routes to stay under ${ROUTES_JSON_MAX_CHARS} characters. Read the rest with caddy_config_get at path 'apps/http/servers/${srv}/routes', or one route at a time with caddy_config_by_id.]`
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
return { content };
|
|
1079
1155
|
}
|
|
1080
1156
|
);
|
|
1081
1157
|
server.tool(
|
package/dist/server.d.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import { McpServer } from
|
|
2
|
-
|
|
3
|
-
declare
|
|
4
|
-
declare function
|
|
5
|
-
declare function startServer(): Promise<void>;
|
|
6
|
-
|
|
7
|
-
export { createCaddyServer, startServer, version };
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export declare const version: string;
|
|
3
|
+
export declare function createCaddyServer(): McpServer;
|
|
4
|
+
export declare function startServer(): Promise<void>;
|
package/dist/server.js
CHANGED
|
@@ -19,11 +19,15 @@ function setEtag(path, etag) {
|
|
|
19
19
|
}
|
|
20
20
|
etagCache.set(path, etag);
|
|
21
21
|
}
|
|
22
|
+
function isAncestorOf(ancestor, descendant) {
|
|
23
|
+
const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
|
|
24
|
+
return descendant.startsWith(`${base}/`);
|
|
25
|
+
}
|
|
22
26
|
function invalidateRelated(path) {
|
|
23
27
|
etagCache.delete(path);
|
|
24
28
|
for (const key of Array.from(etagCache.keys())) {
|
|
25
29
|
if (key === path) continue;
|
|
26
|
-
if (
|
|
30
|
+
if (isAncestorOf(key, path) || isAncestorOf(path, key)) {
|
|
27
31
|
etagCache.delete(key);
|
|
28
32
|
}
|
|
29
33
|
}
|
|
@@ -52,11 +56,20 @@ function getMaxRetries() {
|
|
|
52
56
|
}
|
|
53
57
|
return Math.min(floored, RETRY_HARD_CAP);
|
|
54
58
|
}
|
|
59
|
+
function getAdminOrigin() {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(getBaseUrl()).origin;
|
|
62
|
+
} catch {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
55
66
|
function getHeaders(contentType) {
|
|
56
67
|
const headers = {};
|
|
57
68
|
if (contentType) headers["Content-Type"] = contentType;
|
|
58
69
|
const token = process.env.CADDY_API_TOKEN;
|
|
59
70
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
71
|
+
const origin = getAdminOrigin();
|
|
72
|
+
if (origin) headers.Origin = origin;
|
|
60
73
|
return headers;
|
|
61
74
|
}
|
|
62
75
|
function normalizePath(path) {
|
|
@@ -81,7 +94,9 @@ function isTransientFailure(res) {
|
|
|
81
94
|
if (res.status >= 500 && res.status <= 599) return true;
|
|
82
95
|
return false;
|
|
83
96
|
}
|
|
97
|
+
var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
|
|
84
98
|
function isRetryableMethod(method, path) {
|
|
99
|
+
if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
|
|
85
100
|
if (method !== "POST") return true;
|
|
86
101
|
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
87
102
|
}
|
|
@@ -140,6 +155,13 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
140
155
|
const hint = res.status === 401 || res.status === 403 ? " -- check CADDY_API_TOKEN" : "";
|
|
141
156
|
return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
|
|
142
157
|
}
|
|
158
|
+
if (res.status === 403 && /origin/i.test(text)) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 403,
|
|
162
|
+
error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
143
165
|
return { ok: false, status: res.status, error: text };
|
|
144
166
|
}
|
|
145
167
|
if (!text) return { ok: true, status: res.status, etag };
|
|
@@ -287,7 +309,8 @@ function formatResult(res) {
|
|
|
287
309
|
|
|
288
310
|
// src/tools/operational.ts
|
|
289
311
|
var HTTPS_PORT_RE = /:443(?:\D|$)/;
|
|
290
|
-
function describeServer(
|
|
312
|
+
function describeServer(rawValue) {
|
|
313
|
+
const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
|
291
314
|
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
292
315
|
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
293
316
|
const hasExplicitTls = !!raw.tls_connection_policies;
|
|
@@ -409,7 +432,7 @@ ${lines.join("\n")}` }]
|
|
|
409
432
|
);
|
|
410
433
|
server.tool(
|
|
411
434
|
"caddy_metrics",
|
|
412
|
-
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines
|
|
435
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines, keeping only '# HELP'/'# TYPE' lines for matching metrics; the '# EOF' end-of-file marker is always preserved. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
413
436
|
{
|
|
414
437
|
filter: z.string().optional().describe(
|
|
415
438
|
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
@@ -637,13 +660,25 @@ function registerConfigTools(server) {
|
|
|
637
660
|
);
|
|
638
661
|
server.tool(
|
|
639
662
|
"caddy_load",
|
|
640
|
-
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning.",
|
|
663
|
+
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
|
|
641
664
|
{
|
|
642
665
|
config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
643
|
-
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
|
|
666
|
+
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
|
|
667
|
+
confirm: z3.boolean().optional().default(false).describe("Must be true to replace the running configuration (safety)")
|
|
644
668
|
},
|
|
645
669
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
646
|
-
async ({ config, format }) => {
|
|
670
|
+
async ({ config, format, confirm }) => {
|
|
671
|
+
if (!confirm) {
|
|
672
|
+
return {
|
|
673
|
+
isError: true,
|
|
674
|
+
content: [
|
|
675
|
+
{
|
|
676
|
+
type: "text",
|
|
677
|
+
text: "Refusing to replace the running configuration without confirm=true. caddy_load discards every server and route not present in the supplied config. Re-run with confirm:true to proceed (the prior config is snapshotted and restorable via caddy_revert)."
|
|
678
|
+
}
|
|
679
|
+
]
|
|
680
|
+
};
|
|
681
|
+
}
|
|
647
682
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
648
683
|
const current = await configGet();
|
|
649
684
|
const res = await loadConfig(config, contentType);
|
|
@@ -780,6 +815,23 @@ ${lines.join("\n")}` }] };
|
|
|
780
815
|
|
|
781
816
|
// src/tools/routes.ts
|
|
782
817
|
import { z as z4 } from "zod";
|
|
818
|
+
var ROUTES_JSON_MAX_CHARS = 2e4;
|
|
819
|
+
var ROUTES_SUMMARY_MAX = 500;
|
|
820
|
+
function serializeRoutesCapped(routes) {
|
|
821
|
+
const parts = [];
|
|
822
|
+
let used = 2;
|
|
823
|
+
for (const route of routes) {
|
|
824
|
+
const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
|
|
825
|
+
const cost = entry.length + (parts.length > 0 ? 2 : 1);
|
|
826
|
+
if (parts.length > 0 && used + cost > ROUTES_JSON_MAX_CHARS) break;
|
|
827
|
+
parts.push(entry);
|
|
828
|
+
used += cost;
|
|
829
|
+
}
|
|
830
|
+
if (parts.length === 0) return { json: "[]", shown: 0 };
|
|
831
|
+
return { json: `[
|
|
832
|
+
${parts.join(",\n")}
|
|
833
|
+
]`, shown: parts.length };
|
|
834
|
+
}
|
|
783
835
|
function safeJoin(value) {
|
|
784
836
|
if (!Array.isArray(value)) return "";
|
|
785
837
|
return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
|
|
@@ -792,6 +844,7 @@ function stripPort(host) {
|
|
|
792
844
|
}
|
|
793
845
|
const colonIdx = host.lastIndexOf(":");
|
|
794
846
|
if (colonIdx === -1) return host;
|
|
847
|
+
if (host.indexOf(":") !== colonIdx) return host;
|
|
795
848
|
const portCandidate = host.substring(colonIdx + 1);
|
|
796
849
|
if (portCandidate.length === 0 || !/^\d+$/.test(portCandidate)) return host;
|
|
797
850
|
return host.substring(0, colonIdx);
|
|
@@ -845,9 +898,9 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
845
898
|
function registerRouteTools(server) {
|
|
846
899
|
server.tool(
|
|
847
900
|
"caddy_reverse_proxy",
|
|
848
|
-
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via
|
|
901
|
+
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
|
|
849
902
|
{
|
|
850
|
-
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
903
|
+
from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
851
904
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
852
905
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
853
906
|
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
|
|
@@ -856,7 +909,18 @@ function registerRouteTools(server) {
|
|
|
856
909
|
},
|
|
857
910
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
858
911
|
async ({ from, to, server: srv, id }) => {
|
|
859
|
-
const match = parseFrom(from);
|
|
912
|
+
const match = parseFrom(from.trim());
|
|
913
|
+
if (match.host?.[0]?.trim() === "" || match.host === void 0 && match.path === void 0) {
|
|
914
|
+
return {
|
|
915
|
+
isError: true,
|
|
916
|
+
content: [
|
|
917
|
+
{
|
|
918
|
+
type: "text",
|
|
919
|
+
text: `Error: "from" value ${JSON.stringify(from)} has no host or path to match on. Supply a domain ('api.local'), a path ('/api/*'), or both ('app.local/ws').`
|
|
920
|
+
}
|
|
921
|
+
]
|
|
922
|
+
};
|
|
923
|
+
}
|
|
860
924
|
const cleanedTo = to.map(cleanUpstreamAddr);
|
|
861
925
|
const route = {
|
|
862
926
|
match: [match],
|
|
@@ -883,7 +947,7 @@ function registerRouteTools(server) {
|
|
|
883
947
|
]
|
|
884
948
|
};
|
|
885
949
|
}
|
|
886
|
-
const putRes = await configByIdSet(id, route, "
|
|
950
|
+
const putRes = await configByIdSet(id, route, "PATCH");
|
|
887
951
|
if (putRes.ok) {
|
|
888
952
|
return {
|
|
889
953
|
content: [
|
|
@@ -946,7 +1010,7 @@ function registerRouteTools(server) {
|
|
|
946
1010
|
);
|
|
947
1011
|
server.tool(
|
|
948
1012
|
"caddy_list_routes",
|
|
949
|
-
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
1013
|
+
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers, followed by the raw route JSON. Both halves are capped on large servers: the summary at 500 routes, the JSON at 20000 characters (truncated on whole-route boundaries, so it always parses). When either cap trims output, a note says how many routes were omitted -- read the rest with caddy_config_get at 'apps/http/servers/<server>/routes'.",
|
|
950
1014
|
{
|
|
951
1015
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
952
1016
|
},
|
|
@@ -969,7 +1033,8 @@ function registerRouteTools(server) {
|
|
|
969
1033
|
};
|
|
970
1034
|
}
|
|
971
1035
|
const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
|
|
972
|
-
|
|
1036
|
+
const summarized = Math.min(routes.length, ROUTES_SUMMARY_MAX);
|
|
1037
|
+
for (let i = 0; i < summarized; i++) {
|
|
973
1038
|
const rawRoute = routes[i];
|
|
974
1039
|
if (!rawRoute || typeof rawRoute !== "object") {
|
|
975
1040
|
lines.push(` Route ${i}: <invalid>`);
|
|
@@ -1068,12 +1133,23 @@ function registerRouteTools(server) {
|
|
|
1068
1133
|
const terminal = route.terminal === true ? " [terminal]" : "";
|
|
1069
1134
|
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
|
|
1070
1135
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
{
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
};
|
|
1136
|
+
if (summarized < routes.length) {
|
|
1137
|
+
lines.push(
|
|
1138
|
+
` ... ${routes.length - summarized} more route(s) not shown (summary caps at ${ROUTES_SUMMARY_MAX}).`
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
const { json, shown } = serializeRoutesCapped(routes);
|
|
1142
|
+
const content = [
|
|
1143
|
+
{ type: "text", text: lines.join("\n") },
|
|
1144
|
+
{ type: "text", text: json }
|
|
1145
|
+
];
|
|
1146
|
+
if (shown < routes.length) {
|
|
1147
|
+
content.push({
|
|
1148
|
+
type: "text",
|
|
1149
|
+
text: `[JSON block truncated: showing ${shown} of ${routes.length} routes to stay under ${ROUTES_JSON_MAX_CHARS} characters. Read the rest with caddy_config_get at path 'apps/http/servers/${srv}/routes', or one route at a time with caddy_config_by_id.]`
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return { content };
|
|
1077
1153
|
}
|
|
1078
1154
|
);
|
|
1079
1155
|
server.tool(
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface Snapshot {
|
|
2
|
+
config: unknown;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
trigger: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function saveSnapshot(config: unknown, trigger: string): void;
|
|
7
|
+
export declare function listSnapshots(): readonly Snapshot[];
|
|
8
|
+
export declare function getSnapshot(index: number): Snapshot | undefined;
|
|
9
|
+
export declare function clearSnapshots(): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/** Default max_lines for caddy_metrics. Prometheus output on busy servers can be megabytes; 500 lines is enough to skim. */
|
|
3
|
+
export declare const METRICS_DEFAULT_MAX_LINES = 500;
|
|
4
|
+
/**
|
|
5
|
+
* Apply the optional substring filter and max_lines truncation to raw Prometheus exposition text.
|
|
6
|
+
*
|
|
7
|
+
* Filter rule: a line is kept if the metric name on that line contains the filter substring.
|
|
8
|
+
* Both `# HELP` / `# TYPE` comment lines and sample lines are matched on their metric name, so any
|
|
9
|
+
* retained metric keeps its descriptive comments alongside its samples. Lines with no parseable
|
|
10
|
+
* metric name (blank lines, free-form `#` comments) are dropped when filtering.
|
|
11
|
+
*
|
|
12
|
+
* Truncation: if the resulting line count exceeds `maxLines`, output is cut at `maxLines` and a
|
|
13
|
+
* trailing `# [truncated, N lines omitted -- use filter to narrow]` comment is appended. If the
|
|
14
|
+
* input contained a `# EOF` end-of-file marker that would have been dropped by the cut, it is
|
|
15
|
+
* re-appended after the truncation comment so strict downstream parsers still see a terminated
|
|
16
|
+
* stream.
|
|
17
|
+
*/
|
|
18
|
+
export declare function applyMetricsControls(raw: string, filter: string | undefined, maxLines: number): string;
|
|
19
|
+
export declare function registerOperationalTools(server: McpServer): void;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/** Parse a "from" string like "api.example.com" or "example.com/api/*" into match object */
|
|
3
|
+
export declare function parseFrom(from: string): {
|
|
4
|
+
host?: string[];
|
|
5
|
+
path?: string[];
|
|
6
|
+
};
|
|
7
|
+
export declare function registerRouteTools(server: McpServer): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.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,21 +15,23 @@
|
|
|
15
15
|
"main": "./dist/server.js",
|
|
16
16
|
"types": "./dist/server.d.ts",
|
|
17
17
|
"bin": {
|
|
18
|
-
"caddy-mcp": "
|
|
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",
|
|
24
25
|
"LICENSE"
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|
|
27
|
-
"build": "tsup",
|
|
28
|
+
"build": "tsup && tsc -p tsconfig.build.json",
|
|
28
29
|
"dev": "tsup --watch",
|
|
29
30
|
"test": "vitest run",
|
|
30
31
|
"lint": "biome check src/",
|
|
31
32
|
"lint:fix": "biome check --write src/",
|
|
32
|
-
"typecheck": "
|
|
33
|
+
"typecheck": "node scripts/typecheck.mjs",
|
|
34
|
+
"typecheck:tsc": "tsc --noEmit",
|
|
33
35
|
"test:ci": "npm run build && npm test",
|
|
34
36
|
"prepublishOnly": "npm run build",
|
|
35
37
|
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
|
|
@@ -49,7 +51,7 @@
|
|
|
49
51
|
"esbuild": "^0.28.1"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
|
-
"@biomejs/biome": "
|
|
54
|
+
"@biomejs/biome": "~2.4.11",
|
|
53
55
|
"@types/node": "^26.0.0",
|
|
54
56
|
"postject": "^1.0.0-alpha.6",
|
|
55
57
|
"tsup": "^8.4.0",
|