Caddy's admin API is small, powerful, and easy to corrupt with two overlapping writes. Six MCP servers wrap it, and they disagree about safety.
Caddy has no config file you have to reload. The running configuration is a JSON document behind an admin API, and every change goes through it. That makes Caddy unusually good material for an agent: one endpoint, one document, typed edits. It also means an MCP server for Caddy is holding the only steering wheel your reverse proxy has. About a dozen exist on GitHub. This post compares the ones that talk to the admin API, including ours, npx -y @yawlabs/caddy-mcp, and it is worth saying up front that the Caddy project does not publish one of its own.
Caddy's admin API reference is short enough to read in five minutes, which is why coverage claims are checkable. These are the documented endpoints:
POST /load replace the entire config atomically
POST /stop graceful shutdown
GET /config/[path] read any subtree of the running config
POST /config/[path] append to an array; create or replace an object
PUT /config/[path] insert into an array at an index, or strictly create
PATCH /config/[path] strictly replace an existing value
DELETE /config/[path] remove the value at that path
GET|POST|PUT|PATCH|DELETE /id/<id>/[path] the same operations, addressed by @id
POST /adapt convert a Caddyfile (or other adapter format) to JSON
GET /pki/ca/<id> CA information
GET /pki/ca/<id>/certificates certificate chain
GET /reverse_proxy/upstreams reverse proxy backend healthTwo details in that list do most of the damage in practice. The first is that POST appends to an array while PATCH replaces an existing value. The second is that the endpoint is, by default, reachable on localhost:2019 with no authentication -- Caddy's global options document the default as localhost:2019 unless CADDY_ADMIN is set, and note that setting admin off makes config changes impossible without restarting the server.
Caddy has no multi-request transactions, so it offers optimistic concurrency instead. The docs are explicit: set the If-Match header on a mutative request to the Etag from a previous GET, and a lost race comes back as 412 Precondition Failed rather than as a silent overwrite.
GET /config/apps/http/servers/srv0/routes -> 200 + Etag
(the agent reads, reasons, edits)
PATCH /config/apps/http/servers/srv0/routes/0 If-Match: <that Etag>
-> 200 applied
-> 412 someone changed it first; re-read and re-apply, do not blind-retryThis matters more with an agent than with a human, because an agent's read-reason-write cycle is long: it fetches the config, thinks for several seconds, and writes back a document assembled from what it saw. Anything that changed in between -- your teammate, a deploy script, another agent session -- is gone. @yawlabs/caddy-mcp keeps a path-keyed Etag cache in its API layer, sends If-Match on mutative calls, invalidates parent entries so a nested write does not fire a spurious 412, and surfaces a real 412 as a re-read instruction rather than a stack trace.
The second failure mode is duplication. A model that is asked twice to "proxy api.local to port 3000" will happily call the tool twice, and with POST semantics you now have two routes matching the same host. The first one wins, the second is dead weight, and the config no longer matches anyone's mental model.
caddy_reverse_proxy from=api.local to=[localhost:3000]
# no id: each call appends a route
caddy_reverse_proxy from=api.local to=[localhost:3000] id=api-prod
# with an @id: the first call creates, later calls replace in placeSo caddy_config_set defaults to overwrite (PATCH) rather than append (POST), and the reverse-proxy tool takes an optional @id that makes a re-run idempotent. Destructive operations -- delete a config path, remove a route, stop the server, apply a snapshot -- require an explicit confirm=true. The full surface is 18 tools and 4 read-only resources, which between them reach every endpoint in the list above.
Every server below was read from its own repository, not from a directory listing. Tool counts were checked against each project's own tool list rather than its headline number; the write-posture column comes from reading the source where the README did not say.
| Server | Surface | Write posture | Runtime |
|---|---|---|---|
@yawlabs/caddy-mcp | 18 tools + 4 resources: config, routes, reverse proxies, TLS, PKI, adapt, metrics, snapshots | If-Match on writes, PATCH by default, confirm=true on destructive tools, read and destructive hints on all 18 | Node via npx, MIT, v2.4.0 |
| lum8rjack/caddy-mcp | 6 tools: read config, replace config, adapt Caddyfile / nginx / YAML, upstream health | Whole-config replace; no If-Match, PATCH or tool hints anywhere in main.go | Single Go binary; stdio, SSE or HTTP stream; MIT |
| karti-ai/caddy-mcp-server | 20 tools in site vocabulary: sites, path routes, basic auth, headers, file server, review environments | Direct writes; no If-Match in its client, no tool hints in its server | Go; Apache-2.0 |
| euisuh/caddy-mcp | 7 tools: read config, upstream inventory, validate and adapt Caddyfile text, load, patch, stop | Mutating tools default to dry_run=true and return the exact method, path and value they would send | Python / FastMCP, stdio or SSE; MIT |
| markhaines/caddy-mcp | 6 tools driving the Caddyfile inside the container over the Docker API: read, write, validate, reload, logs, container status | Validate with Caddy's own checker before reload; its README documents that an empty API key disables auth entirely | Python, Docker socket, HTTP; MIT |
| Cabooman/caddy-mcp | Plugin development, not operations: scaffold a plugin, build with xcaddy, dev server, deploy with a binary backup | Replaces the Caddy binary; backs up the old one first | Node + Go + xcaddy; MIT |
One name collision worth knowing about: venkatkrishna07/caddy-mcp is also called caddy-mcp and is not a Caddy management server at all -- it is a Caddy plugin that tunnels other MCP servers over QUIC so private ones can be reached through your existing Caddy. Different job, same search results.
euisuh/caddy-mcp has the better default. Every mutating tool previews instead of applying unless you pass dry_run=false, and the preview is the literal method, path and value. Ours is confirm-gated on the destructive tools but applies an ordinary config write immediately. If you are handing an unfamiliar model the keys to a proxy you care about, preview-first is the safer posture, and it is the design I would copy first.
markhaines/caddy-mcp is right when the Caddyfile is the source of truth. It edits the Caddyfile inside the running container over the Docker API and validates before reloading, which fits the very common homelab shape where Caddy is a container and the Caddyfile is in a git repo. There is a hard reason to prefer that model over an admin-API client in that setup. Since Caddy 2.11.1, per its release notes, "SIGUSR1 can now reload configuration if it was initially loaded from a file on the command line and did not get changed via the API" -- so the first admin-API write from any client, ours included, is what takes that reload path away. Pick one owner per instance.
lum8rjack/caddy-mcp is the no-Node option. A single static Go binary, three transports including HTTP stream, and documented instructions for rebuilding it against a custom Caddy with extra modules. If your box has no Node runtime or you want the server reachable over HTTP rather than stdio, that is a straightforward win.
karti-ai/caddy-mcp-server speaks in tasks, not JSON paths. add_site, add_path_route, add_basic_auth are easier for a model to use correctly than a generic config-path setter, and its README is refreshingly honest about the limits -- it warns that passwords for basic auth travel in plaintext across the MCP channel even though Caddy stores them bcrypt-hashed, and recommends an SSH tunnel for remote admin.
And Cabooman/caddy-mcp does a job none of the others attempt. If you are writing a Caddy plugin rather than operating a proxy, an admin-API server is useless to you and a scaffold-build-deploy toolchain is exactly right.
The admin API is the config. Anything that can reach it can rewrite your TLS settings and your routes. Caddy's own docs raise this for one case in particular: if you are running untrusted code on the server, they tell you to protect the admin endpoint "by isolating processes, patching vulnerable programs, and configuring the endpoint to bind to a permissioned unix socket instead". That is narrower than a blanket recommendation, but it is the same hardening worth reaching for once an agent is driving the endpoint. @yawlabs/caddy-mcp accepts a socket path in CADDY_ADMIN_URL for exactly that reason, and its snapshot directory is opt-in rather than defaulted because a Caddy config can contain secrets.
Do not lean on tool annotations as a safety control either. They are useful -- a client that knows a tool is read-only can skip a confirmation dialog -- but the MCP specification is blunt that "clients MUST consider tool annotations to be untrusted unless they come from trusted servers". They are a hint from the server about itself, not a permission boundary. The boundary is what the process can reach: the socket, the token, the network. If you are thinking about where the proxy layer belongs at all, when an ALB can replace nginx covers the same tradeoff one layer up.
Add it to any MCP client, or let Yaw MCP fan one config out to every client on the machine:
npx -y @yawlabs/caddy-mcpThe card with the environment variables it reads is on our MCP servers page, and the source, including the live-Caddy integration suite pinned against 2.11.4, is at github.com/YawLabs/caddy-mcp.
Not as of September 2026. A repository search across the caddyserver GitHub organization returns no MCP server, so every Caddy MCP server is community-built. Read the tool list and the write path of whichever one you pick before you point it at a live proxy.
No. The admin endpoint listens on localhost:2019 by default, so the usual setup is to run the MCP server on the same host, or to forward the port over SSH. Caddy's own documentation tells you to bind the endpoint to a permissioned unix socket instead if you are running untrusted code on the server, and the yawlabs server accepts a unix socket path in CADDY_ADMIN_URL.
Caddy supports optimistic concurrency: send If-Match with the Etag from your last GET, and a lost race returns 412 Precondition Failed instead of silently overwriting the other change. The yawlabs server caches Etags from config reads and sends If-Match on writes. The two Go servers compared above have no If-Match handling in their source, so there a stale write simply wins.
Jeff Yaw, Yaw Labs. Follow along at tokenlimit.news for weekly notes on AI infrastructure.
Published by Yaw Labs.