@yawlabs/caddy-mcp 1.3.1 → 2.0.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/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 +5 -4
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": "
|
|
3
|
+
"version": "2.0.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",
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "tsup",
|
|
27
|
+
"build": "tsup && tsc -p tsconfig.build.json",
|
|
28
28
|
"dev": "tsup --watch",
|
|
29
29
|
"test": "vitest run",
|
|
30
30
|
"lint": "biome check src/",
|
|
31
31
|
"lint:fix": "biome check --write src/",
|
|
32
|
-
"typecheck": "
|
|
32
|
+
"typecheck": "node scripts/typecheck.mjs",
|
|
33
|
+
"typecheck:tsc": "tsc --noEmit",
|
|
33
34
|
"test:ci": "npm run build && npm test",
|
|
34
35
|
"prepublishOnly": "npm run build",
|
|
35
36
|
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"esbuild": "^0.28.1"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
|
-
"@biomejs/biome": "
|
|
53
|
+
"@biomejs/biome": "~2.4.11",
|
|
53
54
|
"@types/node": "^26.0.0",
|
|
54
55
|
"postject": "^1.0.0-alpha.6",
|
|
55
56
|
"tsup": "^8.4.0",
|