@yawlabs/caddy-mcp 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaw Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @yawlabs/caddy-mcp
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ **MCP server for managing Caddy web servers.** 13 tools for config management, reverse proxy setup, route operations, TLS, and server monitoring — all via Caddy's admin API.
7
+
8
+ Built and maintained by [Yaw Labs](https://yaw.sh).
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ npx @yawlabs/caddy-mcp
14
+ ```
15
+
16
+ Or install globally:
17
+
18
+ ```bash
19
+ npm install -g @yawlabs/caddy-mcp
20
+ caddy-mcp
21
+ ```
22
+
23
+ ## MCP client configuration
24
+
25
+ ### Claude Code
26
+
27
+ ```bash
28
+ claude mcp add caddy-mcp npx @yawlabs/caddy-mcp
29
+ ```
30
+
31
+ ### Claude Desktop / Cursor / Windsurf
32
+
33
+ Add to your MCP config file:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "caddy-mcp": {
39
+ "command": "npx",
40
+ "args": ["@yawlabs/caddy-mcp"],
41
+ "env": {
42
+ "CADDY_ADMIN_URL": "http://localhost:2019"
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ | Environment Variable | Default | Description |
52
+ |---|---|---|
53
+ | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL |
54
+ | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints |
55
+
56
+ ## Tools
57
+
58
+ ### Config management
59
+
60
+ - **caddy_config_get** — Read config at any JSON path (or full config)
61
+ - **caddy_config_set** — Create or replace config at a path
62
+ - **caddy_config_delete** — Delete config at a path
63
+ - **caddy_load** — Replace entire config atomically
64
+
65
+ ### Route operations
66
+
67
+ - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`
68
+ - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler)
69
+ - **caddy_list_routes** — Human-readable route summary
70
+
71
+ ### TLS & config conversion
72
+
73
+ - **caddy_tls** — Check/configure TLS settings, ACME email, CA
74
+ - **caddy_adapt** — Convert Caddyfile to JSON (preview before applying)
75
+
76
+ ### Server operations
77
+
78
+ - **caddy_status** — Connectivity check + config summary
79
+ - **caddy_upstreams** — Reverse proxy backend health
80
+ - **caddy_pki** — CA info and certificate chains
81
+ - **caddy_stop** — Graceful shutdown (requires confirmation)
82
+
83
+ ## Resources
84
+
85
+ - `caddy://config` — Current Caddy JSON configuration
86
+ - `caddy://upstreams` — Reverse proxy upstream health status
87
+
88
+ ## Examples
89
+
90
+ ```
91
+ > "Proxy api.local to my dev server on port 3000"
92
+ → caddy_reverse_proxy(from: "api.local", to: ["localhost:3000"])
93
+
94
+ > "What routes are configured?"
95
+ → caddy_list_routes()
96
+
97
+ > "Show me the full Caddy config"
98
+ → caddy_config_get()
99
+
100
+ > "Convert this Caddyfile to JSON"
101
+ → caddy_adapt(config: "example.com {\n reverse_proxy localhost:8080\n}")
102
+
103
+ > "Is Caddy running?"
104
+ → caddy_status()
105
+ ```
106
+
107
+ ## Requirements
108
+
109
+ - Node.js 18+
110
+ - Caddy server with admin API enabled (default: `localhost:2019`)
111
+
112
+ ## License
113
+
114
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import { createRequire } from "module";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+
8
+ // src/api.ts
9
+ var DEFAULT_URL = "http://localhost:2019";
10
+ var TIMEOUT = 1e4;
11
+ function getBaseUrl() {
12
+ return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
13
+ }
14
+ function getHeaders(contentType = "application/json") {
15
+ const headers = { "Content-Type": contentType };
16
+ const token = process.env.CADDY_API_TOKEN;
17
+ if (token) headers.Authorization = `Bearer ${token}`;
18
+ return headers;
19
+ }
20
+ function normalizePath(path) {
21
+ return path.replace(/^\/?(config\/?)?/, "");
22
+ }
23
+ async function caddyRequest(method, path, body, contentType) {
24
+ const url = `${getBaseUrl()}${path}`;
25
+ try {
26
+ const res = await fetch(url, {
27
+ method,
28
+ headers: getHeaders(contentType),
29
+ body: body !== void 0 ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
30
+ signal: AbortSignal.timeout(TIMEOUT)
31
+ });
32
+ const text = await res.text();
33
+ if (!res.ok) {
34
+ return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
35
+ }
36
+ if (!text) return { ok: true, status: res.status };
37
+ try {
38
+ return { ok: true, status: res.status, data: JSON.parse(text) };
39
+ } catch {
40
+ return { ok: true, status: res.status, data: text };
41
+ }
42
+ } catch (err) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
45
+ return {
46
+ ok: false,
47
+ status: 0,
48
+ error: `Cannot connect to Caddy admin API at ${getBaseUrl()} \u2014 is Caddy running?`
49
+ };
50
+ }
51
+ if (msg.includes("abort") || msg.includes("timeout")) {
52
+ return { ok: false, status: 0, error: `Request timed out after ${TIMEOUT}ms` };
53
+ }
54
+ return { ok: false, status: 0, error: msg };
55
+ }
56
+ }
57
+ function configGet(path = "") {
58
+ const normalized = normalizePath(path);
59
+ return caddyRequest("GET", `/config/${normalized}`);
60
+ }
61
+ function configPost(path, value) {
62
+ const normalized = normalizePath(path);
63
+ return caddyRequest("POST", `/config/${normalized}`, value);
64
+ }
65
+ function configPatch(path, value) {
66
+ const normalized = normalizePath(path);
67
+ return caddyRequest("PATCH", `/config/${normalized}`, value);
68
+ }
69
+ function configDelete(path) {
70
+ const normalized = normalizePath(path);
71
+ return caddyRequest("DELETE", `/config/${normalized}`);
72
+ }
73
+ function loadConfig(config) {
74
+ return caddyRequest("POST", "/load", config);
75
+ }
76
+ function adapt(config, adapter = "caddyfile") {
77
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
78
+ }
79
+ function stop() {
80
+ return caddyRequest("POST", "/stop");
81
+ }
82
+ function getUpstreams() {
83
+ return caddyRequest("GET", "/reverse_proxy/upstreams");
84
+ }
85
+ function getPki(ca = "local") {
86
+ return caddyRequest("GET", `/pki/ca/${ca}`);
87
+ }
88
+ function getPkiCertificates(ca = "local") {
89
+ return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
90
+ }
91
+
92
+ // src/resources.ts
93
+ function registerResources(server) {
94
+ server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
95
+ const res = await configGet();
96
+ return {
97
+ contents: [
98
+ {
99
+ uri: "caddy://config",
100
+ mimeType: "application/json",
101
+ text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
102
+ }
103
+ ]
104
+ };
105
+ });
106
+ server.resource(
107
+ "caddy-upstreams",
108
+ "caddy://upstreams",
109
+ { description: "Reverse proxy upstream health status" },
110
+ async () => {
111
+ const res = await getUpstreams();
112
+ return {
113
+ contents: [
114
+ {
115
+ uri: "caddy://upstreams",
116
+ mimeType: "application/json",
117
+ text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
118
+ }
119
+ ]
120
+ };
121
+ }
122
+ );
123
+ }
124
+
125
+ // src/tools/adapt.ts
126
+ import { z } from "zod";
127
+
128
+ // src/format.ts
129
+ function formatResult(res) {
130
+ if (!res.ok) {
131
+ return {
132
+ isError: true,
133
+ content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
134
+ };
135
+ }
136
+ const text = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "OK";
137
+ return { content: [{ type: "text", text }] };
138
+ }
139
+
140
+ // src/tools/adapt.ts
141
+ function registerAdaptTools(server) {
142
+ server.tool(
143
+ "caddy_adapt",
144
+ "Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces.",
145
+ {
146
+ config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
147
+ adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
148
+ },
149
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
150
+ async ({ config, adapter }) => formatResult(await adapt(config, adapter))
151
+ );
152
+ }
153
+
154
+ // src/tools/config.ts
155
+ import { z as z2 } from "zod";
156
+ function registerConfigTools(server) {
157
+ server.tool(
158
+ "caddy_config_get",
159
+ "Read Caddy config at any JSON path. Returns the full config when path is empty, or a subtree at a specific path (e.g., 'apps/http/servers/srv0/routes').",
160
+ { path: z2.string().optional().default("").describe("Config path (e.g., 'apps/http/servers/srv0')") },
161
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
162
+ async ({ path }) => formatResult(await configGet(path))
163
+ );
164
+ server.tool(
165
+ "caddy_config_set",
166
+ "Create or replace config at a JSON path. Mode 'create' (default) appends to arrays or creates objects (POST). Mode 'replace' overwrites existing values (PATCH).",
167
+ {
168
+ path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
169
+ value: z2.any().describe("The JSON value to set at the path"),
170
+ mode: z2.enum(["create", "replace"]).optional().default("create").describe("'create' = POST (append), 'replace' = PATCH (overwrite)")
171
+ },
172
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
173
+ async ({ path, value, mode }) => {
174
+ const res = mode === "replace" ? await configPatch(path, value) : await configPost(path, value);
175
+ return formatResult(res);
176
+ }
177
+ );
178
+ server.tool(
179
+ "caddy_config_delete",
180
+ "Delete config at a JSON path. Removes the config node at the specified path.",
181
+ { path: z2.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
182
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
183
+ async ({ path }) => formatResult(await configDelete(path))
184
+ );
185
+ server.tool(
186
+ "caddy_load",
187
+ "Replace the entire Caddy configuration atomically. Accepts a full Caddy JSON config object. This is the safest way to make large config changes.",
188
+ { config: z2.record(z2.any()).describe("Full Caddy JSON configuration object") },
189
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
190
+ async ({ config }) => formatResult(await loadConfig(config))
191
+ );
192
+ }
193
+
194
+ // src/tools/operational.ts
195
+ import { z as z3 } from "zod";
196
+ function registerOperationalTools(server) {
197
+ server.tool(
198
+ "caddy_status",
199
+ "Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
200
+ {},
201
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
202
+ async () => {
203
+ const res = await configGet();
204
+ if (!res.ok) return formatResult(res);
205
+ const config = res.data || {};
206
+ const httpApp = config?.apps?.http;
207
+ const servers = httpApp?.servers || {};
208
+ const serverNames = Object.keys(servers);
209
+ const lines = ["Caddy is running", ""];
210
+ if (serverNames.length === 0) {
211
+ lines.push("No HTTP servers configured");
212
+ } else {
213
+ for (const name of serverNames) {
214
+ const srv = servers[name];
215
+ const listen = srv.listen || [];
216
+ const routes = srv.routes || [];
217
+ const tls = srv.tls_connection_policies ? "enabled" : "auto";
218
+ lines.push(
219
+ `Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
220
+ );
221
+ }
222
+ }
223
+ const tlsApp = config?.apps?.tls;
224
+ if (tlsApp?.automation?.policies) {
225
+ const email = tlsApp.automation.policies.find((p) => p.issuers)?.issuers?.[0]?.email;
226
+ if (email) lines.push(`
227
+ ACME email: ${email}`);
228
+ }
229
+ return { content: [{ type: "text", text: lines.join("\n") }] };
230
+ }
231
+ );
232
+ server.tool(
233
+ "caddy_upstreams",
234
+ "Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
235
+ {},
236
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
237
+ async () => formatResult(await getUpstreams())
238
+ );
239
+ server.tool(
240
+ "caddy_pki",
241
+ "Get PKI certificate authority info or the CA certificate chain.",
242
+ {
243
+ ca: z3.string().optional().default("local").describe("CA ID (default: 'local')"),
244
+ certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
245
+ },
246
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
247
+ async ({ ca, certificates }) => {
248
+ const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
249
+ return formatResult(res);
250
+ }
251
+ );
252
+ server.tool(
253
+ "caddy_stop",
254
+ "Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
255
+ { confirm: z3.boolean().describe("Must be true to confirm shutdown") },
256
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
257
+ async ({ confirm }) => {
258
+ if (!confirm) {
259
+ return {
260
+ isError: true,
261
+ content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
262
+ };
263
+ }
264
+ return formatResult(await stop());
265
+ }
266
+ );
267
+ }
268
+
269
+ // src/tools/routes.ts
270
+ import { z as z4 } from "zod";
271
+ function parseFrom(from) {
272
+ const match = {};
273
+ const slashIdx = from.indexOf("/");
274
+ if (slashIdx > 0) {
275
+ match.host = [from.substring(0, slashIdx)];
276
+ match.path = [from.substring(slashIdx)];
277
+ } else if (from.startsWith("/")) {
278
+ match.path = [from];
279
+ } else {
280
+ match.host = [from];
281
+ }
282
+ return match;
283
+ }
284
+ function registerRouteTools(server) {
285
+ server.tool(
286
+ "caddy_reverse_proxy",
287
+ "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'].",
288
+ {
289
+ from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
290
+ to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
291
+ server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
292
+ },
293
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
294
+ async ({ from, to, server: srv }) => {
295
+ const match = parseFrom(from);
296
+ const route = {
297
+ match: [match],
298
+ handle: [
299
+ {
300
+ handler: "reverse_proxy",
301
+ upstreams: to.map((addr) => ({ dial: addr }))
302
+ }
303
+ ],
304
+ terminal: true
305
+ };
306
+ const res = await configPost(`apps/http/servers/${srv}/routes`, route);
307
+ if (res.ok) {
308
+ return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${to.join(", ")}` }] };
309
+ }
310
+ return formatResult(res);
311
+ }
312
+ );
313
+ server.tool(
314
+ "caddy_add_route",
315
+ "Add a route with full control over match conditions and handlers. Supports any Caddy handler (reverse_proxy, file_server, static_response, redirect, encode, headers, etc.).",
316
+ {
317
+ match: z4.array(z4.record(z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
318
+ handle: z4.array(z4.record(z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
319
+ server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)"),
320
+ terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
321
+ },
322
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
323
+ async ({ match, handle, server: srv, terminal }) => {
324
+ const route = { match, handle, terminal };
325
+ const res = await configPost(`apps/http/servers/${srv}/routes`, route);
326
+ return formatResult(res);
327
+ }
328
+ );
329
+ server.tool(
330
+ "caddy_list_routes",
331
+ "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
332
+ {
333
+ server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
334
+ },
335
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
336
+ async ({ server: srv }) => {
337
+ const serverRes = await configGet(`apps/http/servers/${srv}`);
338
+ if (!serverRes.ok) return formatResult(serverRes);
339
+ const serverConfig = serverRes.data;
340
+ const routes = serverConfig?.routes || [];
341
+ const listen = serverConfig?.listen || [];
342
+ if (routes.length === 0) {
343
+ return {
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: `Server ${srv} (listen: ${listen.join(", ") || "default"}) \u2014 no routes configured`
348
+ }
349
+ ]
350
+ };
351
+ }
352
+ const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
353
+ for (let i = 0; i < routes.length; i++) {
354
+ const route = routes[i];
355
+ const matchers = (route.match || []).map((m) => {
356
+ const parts = [];
357
+ if (m.host) parts.push(`host=[${m.host.join(",")}]`);
358
+ if (m.path) parts.push(`path=[${m.path.join(",")}]`);
359
+ if (m.method) parts.push(`method=[${m.method.join(",")}]`);
360
+ if (m.header) parts.push("header=...");
361
+ if (parts.length === 0) return "catch-all";
362
+ return parts.join(" ");
363
+ }).join(" | ");
364
+ const handlers = (route.handle || []).map((h) => {
365
+ if (h.handler === "reverse_proxy") {
366
+ const upstreams = (h.upstreams || []).map((u) => u.dial).join(",");
367
+ return `reverse_proxy(${upstreams})`;
368
+ }
369
+ if (h.handler === "file_server") return `file_server(${h.root || "."})`;
370
+ if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
371
+ if (h.handler === "encode") return "encode";
372
+ if (h.handler === "headers") return "headers";
373
+ return h.handler || "unknown";
374
+ }).join(" \u2192 ");
375
+ lines.push(` Route ${i}: ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
376
+ }
377
+ return {
378
+ content: [
379
+ { type: "text", text: lines.join("\n") },
380
+ { type: "text", text: JSON.stringify(routes, null, 2) }
381
+ ]
382
+ };
383
+ }
384
+ );
385
+ }
386
+
387
+ // src/tools/tls.ts
388
+ import { z as z5 } from "zod";
389
+ function registerTlsTools(server) {
390
+ server.tool(
391
+ "caddy_tls",
392
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL.",
393
+ {
394
+ action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
395
+ email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
396
+ ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
397
+ },
398
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
399
+ async ({ action, email, ca }) => {
400
+ if (action === "status") {
401
+ return formatResult(await configGet("apps/tls"));
402
+ }
403
+ if (action === "set_email") {
404
+ if (!email)
405
+ return {
406
+ isError: true,
407
+ content: [{ type: "text", text: "Error: email is required for set_email action" }]
408
+ };
409
+ const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
410
+ if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
411
+ return formatResult(res);
412
+ }
413
+ if (action === "set_acme_ca") {
414
+ if (!ca)
415
+ return {
416
+ isError: true,
417
+ content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
418
+ };
419
+ const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
420
+ if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
421
+ return formatResult(res);
422
+ }
423
+ return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
424
+ }
425
+ );
426
+ }
427
+
428
+ // src/server.ts
429
+ var require2 = createRequire(import.meta.url);
430
+ var { version } = require2("../package.json");
431
+ function createCaddyServer() {
432
+ const server = new McpServer({ name: "caddy-mcp", version });
433
+ registerConfigTools(server);
434
+ registerRouteTools(server);
435
+ registerAdaptTools(server);
436
+ registerTlsTools(server);
437
+ registerOperationalTools(server);
438
+ registerResources(server);
439
+ return server;
440
+ }
441
+ async function startServer() {
442
+ const server = createCaddyServer();
443
+ const transport = new StdioServerTransport();
444
+ await server.connect(transport);
445
+ }
446
+ var isDirectRun = process.argv[1]?.endsWith("server.js");
447
+ if (isDirectRun) {
448
+ startServer().catch((err) => {
449
+ console.error("MCP server error:", err);
450
+ process.exit(1);
451
+ });
452
+ }
453
+
454
+ // src/index.ts
455
+ startServer().catch((err) => {
456
+ console.error("caddy-mcp error:", err);
457
+ process.exit(1);
458
+ });
@@ -0,0 +1,6 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+
3
+ declare function createCaddyServer(): McpServer;
4
+ declare function startServer(): Promise<void>;
5
+
6
+ export { createCaddyServer, startServer };
package/dist/server.js ADDED
@@ -0,0 +1,454 @@
1
+ // src/server.ts
2
+ import { createRequire } from "module";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/api.ts
7
+ var DEFAULT_URL = "http://localhost:2019";
8
+ var TIMEOUT = 1e4;
9
+ function getBaseUrl() {
10
+ return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
11
+ }
12
+ function getHeaders(contentType = "application/json") {
13
+ const headers = { "Content-Type": contentType };
14
+ const token = process.env.CADDY_API_TOKEN;
15
+ if (token) headers.Authorization = `Bearer ${token}`;
16
+ return headers;
17
+ }
18
+ function normalizePath(path) {
19
+ return path.replace(/^\/?(config\/?)?/, "");
20
+ }
21
+ async function caddyRequest(method, path, body, contentType) {
22
+ const url = `${getBaseUrl()}${path}`;
23
+ try {
24
+ const res = await fetch(url, {
25
+ method,
26
+ headers: getHeaders(contentType),
27
+ body: body !== void 0 ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
28
+ signal: AbortSignal.timeout(TIMEOUT)
29
+ });
30
+ const text = await res.text();
31
+ if (!res.ok) {
32
+ return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
33
+ }
34
+ if (!text) return { ok: true, status: res.status };
35
+ try {
36
+ return { ok: true, status: res.status, data: JSON.parse(text) };
37
+ } catch {
38
+ return { ok: true, status: res.status, data: text };
39
+ }
40
+ } catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
43
+ return {
44
+ ok: false,
45
+ status: 0,
46
+ error: `Cannot connect to Caddy admin API at ${getBaseUrl()} \u2014 is Caddy running?`
47
+ };
48
+ }
49
+ if (msg.includes("abort") || msg.includes("timeout")) {
50
+ return { ok: false, status: 0, error: `Request timed out after ${TIMEOUT}ms` };
51
+ }
52
+ return { ok: false, status: 0, error: msg };
53
+ }
54
+ }
55
+ function configGet(path = "") {
56
+ const normalized = normalizePath(path);
57
+ return caddyRequest("GET", `/config/${normalized}`);
58
+ }
59
+ function configPost(path, value) {
60
+ const normalized = normalizePath(path);
61
+ return caddyRequest("POST", `/config/${normalized}`, value);
62
+ }
63
+ function configPatch(path, value) {
64
+ const normalized = normalizePath(path);
65
+ return caddyRequest("PATCH", `/config/${normalized}`, value);
66
+ }
67
+ function configDelete(path) {
68
+ const normalized = normalizePath(path);
69
+ return caddyRequest("DELETE", `/config/${normalized}`);
70
+ }
71
+ function loadConfig(config) {
72
+ return caddyRequest("POST", "/load", config);
73
+ }
74
+ function adapt(config, adapter = "caddyfile") {
75
+ return caddyRequest("POST", "/adapt", config, `text/${adapter}`);
76
+ }
77
+ function stop() {
78
+ return caddyRequest("POST", "/stop");
79
+ }
80
+ function getUpstreams() {
81
+ return caddyRequest("GET", "/reverse_proxy/upstreams");
82
+ }
83
+ function getPki(ca = "local") {
84
+ return caddyRequest("GET", `/pki/ca/${ca}`);
85
+ }
86
+ function getPkiCertificates(ca = "local") {
87
+ return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
88
+ }
89
+
90
+ // src/resources.ts
91
+ function registerResources(server) {
92
+ server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
93
+ const res = await configGet();
94
+ return {
95
+ contents: [
96
+ {
97
+ uri: "caddy://config",
98
+ mimeType: "application/json",
99
+ text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
100
+ }
101
+ ]
102
+ };
103
+ });
104
+ server.resource(
105
+ "caddy-upstreams",
106
+ "caddy://upstreams",
107
+ { description: "Reverse proxy upstream health status" },
108
+ async () => {
109
+ const res = await getUpstreams();
110
+ return {
111
+ contents: [
112
+ {
113
+ uri: "caddy://upstreams",
114
+ mimeType: "application/json",
115
+ text: res.ok ? JSON.stringify(res.data, null, 2) : `Error: ${res.error}`
116
+ }
117
+ ]
118
+ };
119
+ }
120
+ );
121
+ }
122
+
123
+ // src/tools/adapt.ts
124
+ import { z } from "zod";
125
+
126
+ // src/format.ts
127
+ function formatResult(res) {
128
+ if (!res.ok) {
129
+ return {
130
+ isError: true,
131
+ content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
132
+ };
133
+ }
134
+ const text = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "OK";
135
+ return { content: [{ type: "text", text }] };
136
+ }
137
+
138
+ // src/tools/adapt.ts
139
+ function registerAdaptTools(server) {
140
+ server.tool(
141
+ "caddy_adapt",
142
+ "Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces.",
143
+ {
144
+ config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
145
+ adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
146
+ },
147
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
148
+ async ({ config, adapter }) => formatResult(await adapt(config, adapter))
149
+ );
150
+ }
151
+
152
+ // src/tools/config.ts
153
+ import { z as z2 } from "zod";
154
+ function registerConfigTools(server) {
155
+ server.tool(
156
+ "caddy_config_get",
157
+ "Read Caddy config at any JSON path. Returns the full config when path is empty, or a subtree at a specific path (e.g., 'apps/http/servers/srv0/routes').",
158
+ { path: z2.string().optional().default("").describe("Config path (e.g., 'apps/http/servers/srv0')") },
159
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
160
+ async ({ path }) => formatResult(await configGet(path))
161
+ );
162
+ server.tool(
163
+ "caddy_config_set",
164
+ "Create or replace config at a JSON path. Mode 'create' (default) appends to arrays or creates objects (POST). Mode 'replace' overwrites existing values (PATCH).",
165
+ {
166
+ path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
167
+ value: z2.any().describe("The JSON value to set at the path"),
168
+ mode: z2.enum(["create", "replace"]).optional().default("create").describe("'create' = POST (append), 'replace' = PATCH (overwrite)")
169
+ },
170
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
171
+ async ({ path, value, mode }) => {
172
+ const res = mode === "replace" ? await configPatch(path, value) : await configPost(path, value);
173
+ return formatResult(res);
174
+ }
175
+ );
176
+ server.tool(
177
+ "caddy_config_delete",
178
+ "Delete config at a JSON path. Removes the config node at the specified path.",
179
+ { path: z2.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
180
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
181
+ async ({ path }) => formatResult(await configDelete(path))
182
+ );
183
+ server.tool(
184
+ "caddy_load",
185
+ "Replace the entire Caddy configuration atomically. Accepts a full Caddy JSON config object. This is the safest way to make large config changes.",
186
+ { config: z2.record(z2.any()).describe("Full Caddy JSON configuration object") },
187
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
188
+ async ({ config }) => formatResult(await loadConfig(config))
189
+ );
190
+ }
191
+
192
+ // src/tools/operational.ts
193
+ import { z as z3 } from "zod";
194
+ function registerOperationalTools(server) {
195
+ server.tool(
196
+ "caddy_status",
197
+ "Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
198
+ {},
199
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
200
+ async () => {
201
+ const res = await configGet();
202
+ if (!res.ok) return formatResult(res);
203
+ const config = res.data || {};
204
+ const httpApp = config?.apps?.http;
205
+ const servers = httpApp?.servers || {};
206
+ const serverNames = Object.keys(servers);
207
+ const lines = ["Caddy is running", ""];
208
+ if (serverNames.length === 0) {
209
+ lines.push("No HTTP servers configured");
210
+ } else {
211
+ for (const name of serverNames) {
212
+ const srv = servers[name];
213
+ const listen = srv.listen || [];
214
+ const routes = srv.routes || [];
215
+ const tls = srv.tls_connection_policies ? "enabled" : "auto";
216
+ lines.push(
217
+ `Server "${name}": ${routes.length} route(s), listen: ${listen.join(", ") || "default"}, TLS: ${tls}`
218
+ );
219
+ }
220
+ }
221
+ const tlsApp = config?.apps?.tls;
222
+ if (tlsApp?.automation?.policies) {
223
+ const email = tlsApp.automation.policies.find((p) => p.issuers)?.issuers?.[0]?.email;
224
+ if (email) lines.push(`
225
+ ACME email: ${email}`);
226
+ }
227
+ return { content: [{ type: "text", text: lines.join("\n") }] };
228
+ }
229
+ );
230
+ server.tool(
231
+ "caddy_upstreams",
232
+ "Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
233
+ {},
234
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
235
+ async () => formatResult(await getUpstreams())
236
+ );
237
+ server.tool(
238
+ "caddy_pki",
239
+ "Get PKI certificate authority info or the CA certificate chain.",
240
+ {
241
+ ca: z3.string().optional().default("local").describe("CA ID (default: 'local')"),
242
+ certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
243
+ },
244
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
245
+ async ({ ca, certificates }) => {
246
+ const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
247
+ return formatResult(res);
248
+ }
249
+ );
250
+ server.tool(
251
+ "caddy_stop",
252
+ "Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
253
+ { confirm: z3.boolean().describe("Must be true to confirm shutdown") },
254
+ { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
255
+ async ({ confirm }) => {
256
+ if (!confirm) {
257
+ return {
258
+ isError: true,
259
+ content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
260
+ };
261
+ }
262
+ return formatResult(await stop());
263
+ }
264
+ );
265
+ }
266
+
267
+ // src/tools/routes.ts
268
+ import { z as z4 } from "zod";
269
+ function parseFrom(from) {
270
+ const match = {};
271
+ const slashIdx = from.indexOf("/");
272
+ if (slashIdx > 0) {
273
+ match.host = [from.substring(0, slashIdx)];
274
+ match.path = [from.substring(slashIdx)];
275
+ } else if (from.startsWith("/")) {
276
+ match.path = [from];
277
+ } else {
278
+ match.host = [from];
279
+ }
280
+ return match;
281
+ }
282
+ function registerRouteTools(server) {
283
+ server.tool(
284
+ "caddy_reverse_proxy",
285
+ "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'].",
286
+ {
287
+ from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
288
+ to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
289
+ server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
290
+ },
291
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
292
+ async ({ from, to, server: srv }) => {
293
+ const match = parseFrom(from);
294
+ const route = {
295
+ match: [match],
296
+ handle: [
297
+ {
298
+ handler: "reverse_proxy",
299
+ upstreams: to.map((addr) => ({ dial: addr }))
300
+ }
301
+ ],
302
+ terminal: true
303
+ };
304
+ const res = await configPost(`apps/http/servers/${srv}/routes`, route);
305
+ if (res.ok) {
306
+ return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${to.join(", ")}` }] };
307
+ }
308
+ return formatResult(res);
309
+ }
310
+ );
311
+ server.tool(
312
+ "caddy_add_route",
313
+ "Add a route with full control over match conditions and handlers. Supports any Caddy handler (reverse_proxy, file_server, static_response, redirect, encode, headers, etc.).",
314
+ {
315
+ match: z4.array(z4.record(z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
316
+ handle: z4.array(z4.record(z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
317
+ server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)"),
318
+ terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
319
+ },
320
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
321
+ async ({ match, handle, server: srv, terminal }) => {
322
+ const route = { match, handle, terminal };
323
+ const res = await configPost(`apps/http/servers/${srv}/routes`, route);
324
+ return formatResult(res);
325
+ }
326
+ );
327
+ server.tool(
328
+ "caddy_list_routes",
329
+ "List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
330
+ {
331
+ server: z4.string().optional().default("srv0").describe("Caddy server name (default: srv0)")
332
+ },
333
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
334
+ async ({ server: srv }) => {
335
+ const serverRes = await configGet(`apps/http/servers/${srv}`);
336
+ if (!serverRes.ok) return formatResult(serverRes);
337
+ const serverConfig = serverRes.data;
338
+ const routes = serverConfig?.routes || [];
339
+ const listen = serverConfig?.listen || [];
340
+ if (routes.length === 0) {
341
+ return {
342
+ content: [
343
+ {
344
+ type: "text",
345
+ text: `Server ${srv} (listen: ${listen.join(", ") || "default"}) \u2014 no routes configured`
346
+ }
347
+ ]
348
+ };
349
+ }
350
+ const lines = [`Server: ${srv} (listen: ${listen.join(", ") || "default"})`, ""];
351
+ for (let i = 0; i < routes.length; i++) {
352
+ const route = routes[i];
353
+ const matchers = (route.match || []).map((m) => {
354
+ const parts = [];
355
+ if (m.host) parts.push(`host=[${m.host.join(",")}]`);
356
+ if (m.path) parts.push(`path=[${m.path.join(",")}]`);
357
+ if (m.method) parts.push(`method=[${m.method.join(",")}]`);
358
+ if (m.header) parts.push("header=...");
359
+ if (parts.length === 0) return "catch-all";
360
+ return parts.join(" ");
361
+ }).join(" | ");
362
+ const handlers = (route.handle || []).map((h) => {
363
+ if (h.handler === "reverse_proxy") {
364
+ const upstreams = (h.upstreams || []).map((u) => u.dial).join(",");
365
+ return `reverse_proxy(${upstreams})`;
366
+ }
367
+ if (h.handler === "file_server") return `file_server(${h.root || "."})`;
368
+ if (h.handler === "static_response") return `static_response(${h.status_code || 200})`;
369
+ if (h.handler === "encode") return "encode";
370
+ if (h.handler === "headers") return "headers";
371
+ return h.handler || "unknown";
372
+ }).join(" \u2192 ");
373
+ lines.push(` Route ${i}: ${matchers} \u2192 ${handlers}${route.terminal ? " [terminal]" : ""}`);
374
+ }
375
+ return {
376
+ content: [
377
+ { type: "text", text: lines.join("\n") },
378
+ { type: "text", text: JSON.stringify(routes, null, 2) }
379
+ ]
380
+ };
381
+ }
382
+ );
383
+ }
384
+
385
+ // src/tools/tls.ts
386
+ import { z as z5 } from "zod";
387
+ function registerTlsTools(server) {
388
+ server.tool(
389
+ "caddy_tls",
390
+ "Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL.",
391
+ {
392
+ action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
393
+ email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
394
+ ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
395
+ },
396
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
397
+ async ({ action, email, ca }) => {
398
+ if (action === "status") {
399
+ return formatResult(await configGet("apps/tls"));
400
+ }
401
+ if (action === "set_email") {
402
+ if (!email)
403
+ return {
404
+ isError: true,
405
+ content: [{ type: "text", text: "Error: email is required for set_email action" }]
406
+ };
407
+ const res = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
408
+ if (res.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
409
+ return formatResult(res);
410
+ }
411
+ if (action === "set_acme_ca") {
412
+ if (!ca)
413
+ return {
414
+ isError: true,
415
+ content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
416
+ };
417
+ const res = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
418
+ if (res.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
419
+ return formatResult(res);
420
+ }
421
+ return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
422
+ }
423
+ );
424
+ }
425
+
426
+ // src/server.ts
427
+ var require2 = createRequire(import.meta.url);
428
+ var { version } = require2("../package.json");
429
+ function createCaddyServer() {
430
+ const server = new McpServer({ name: "caddy-mcp", version });
431
+ registerConfigTools(server);
432
+ registerRouteTools(server);
433
+ registerAdaptTools(server);
434
+ registerTlsTools(server);
435
+ registerOperationalTools(server);
436
+ registerResources(server);
437
+ return server;
438
+ }
439
+ async function startServer() {
440
+ const server = createCaddyServer();
441
+ const transport = new StdioServerTransport();
442
+ await server.connect(transport);
443
+ }
444
+ var isDirectRun = process.argv[1]?.endsWith("server.js");
445
+ if (isDirectRun) {
446
+ startServer().catch((err) => {
447
+ console.error("MCP server error:", err);
448
+ process.exit(1);
449
+ });
450
+ }
451
+ export {
452
+ createCaddyServer,
453
+ startServer
454
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@yawlabs/caddy-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for managing Caddy web servers via the admin API",
5
+ "license": "MIT",
6
+ "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/server.js",
11
+ "types": "./dist/server.d.ts"
12
+ }
13
+ },
14
+ "main": "./dist/server.js",
15
+ "types": "./dist/server.d.ts",
16
+ "bin": {
17
+ "caddy-mcp": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "!dist/**/*.test.*",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsup --watch",
28
+ "test": "vitest run",
29
+ "lint": "biome check src/",
30
+ "lint:fix": "biome check --write src/",
31
+ "typecheck": "tsc --noEmit",
32
+ "test:ci": "npm run build && npm test",
33
+ "prepublishOnly": "npm run build",
34
+ "start": "node dist/index.js"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.29.0",
38
+ "zod": "^3.24.4"
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^1.9.4",
42
+ "@types/node": "^25.5.2",
43
+ "tsup": "^8.4.0",
44
+ "typescript": "^5.8.3",
45
+ "vitest": "^3.1.1"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "keywords": [
51
+ "mcp",
52
+ "model-context-protocol",
53
+ "caddy",
54
+ "reverse-proxy",
55
+ "web-server",
56
+ "mcp-server",
57
+ "devtools",
58
+ "ai"
59
+ ],
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/YawLabs/caddy-mcp.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/YawLabs/caddy-mcp/issues"
66
+ },
67
+ "homepage": "https://github.com/YawLabs/caddy-mcp"
68
+ }