Initial commit: Caddy MCP Server

This commit is contained in:
2026-04-10 21:57:20 -07:00
commit 2a3107f9e2
21 changed files with 2194 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# Compiled binaries
caddy-mcp
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Build artifacts
dist/
build/
bin/
release/
# Go specific
*.o
*.a
*.log
vendor/
Godeps/
go.work
go.work.sum
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Test artifacts
*.tmp
tmp/
temp/
coverage.out
coverage.html
*.cover
# Environment files
.env
.env.local
.env.*.local
*.env
# Debug
debug/
__debug_bin
# OS
Thumbs.db
.sisyphus/
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Lumbridge Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+117
View File
@@ -0,0 +1,117 @@
# Caddy MCP Server
A comprehensive Model Context Protocol (MCP) server for managing Caddy web server via the Admin API.
## Features
Complete Caddy management with 21 powerful tools:
### Site Management
| Tool | Description |
|------|-------------|
| `list_sites` | List all configured sites with upstreams & auth status |
| `get_site` | Get detailed configuration for a specific site |
| `get_site_routes` | Get all routes (including path-based) for a site |
| `add_site` | Add a new site with reverse proxy |
| `remove_site` | Remove a site configuration |
| `update_upstream` | Update the upstream address for a site |
### Path-Based Routing
| Tool | Description |
|------|-------------|
| `add_path_route` | Add path-based route (e.g., `/api/*` → localhost:3000) |
| `remove_path_route` | Remove a path-based route |
### Authentication
| Tool | Description |
|------|-------------|
| `add_basic_auth` | Add basic authentication to a site |
| `remove_basic_auth` | Remove basic authentication |
### Headers
| Tool | Description |
|------|-------------|
| `add_request_header` | Add header sent to upstream |
| `add_response_header` | Add header sent to client |
### File Server
| Tool | Description |
|------|-------------|
| `add_file_server` | Add static file server |
| `update_file_root` | Update file server root directory |
### Review Environments
| Tool | Description |
|------|-------------|
| `add_wip_environment` | Add WIP review env to review.example.com |
### Configuration
| Tool | Description |
|------|-------------|
| `export_config` | Export full Caddy JSON config |
| `adapt_caddyfile` | Convert Caddyfile to JSON |
| `validate_config` | Validate config without applying |
### Server Control
| Tool | Description |
|------|-------------|
| `caddy_status` | Check Caddy health |
| `stop_caddy` | Gracefully stop Caddy |
## Usage
```bash
# Build
go build -o caddy-mcp .
# Run locally
./caddy-mcp
# Run against remote Caddy (via SSH tunnel)
ssh -L 2019:localhost:2019 server-1 &
./caddy-mcp
```
## Environment
- `CADDY_HOST` - Caddy admin API host (default: `localhost:2019`)
- `REVIEW_DOMAIN` - Domain for WIP review environments (default: `review.example.com`)
## Architecture
- `client.go` - Caddy Admin API client
- `server.go` - MCP server setup
- `handlers.go` - Tool implementations
- `site_ops.go` - Site read operations
- `site_modify.go` - Site write operations
- `path_routes.go` - Path-based routing
- `auth_ops.go` - Authentication management
- `headers.go` - Header manipulation
- `fileserver.go` - Static file serving
## Security
### Password Transmission
**WARNING**: Passwords are transmitted in plaintext from the MCP client to this server.
This is an architectural limitation of the Model Context Protocol (MCP).
While Caddy stores passwords hashed (bcrypt), the password travels unencrypted
through the MCP communication channel.
### Caddy Admin API
The Caddy admin API (`localhost:2019` by default) uses HTTP, not HTTPS.
When running this MCP server remotely, use SSH port forwarding to secure the connection:
```bash
ssh -L 2019:localhost:2019 <remote-server> &
./caddy-mcp
```
## Examples
```bash
# List all sites
./caddy-mcp
# Send: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_sites","arguments":{}}}
# Add a WIP environment
# {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add_wip_environment","arguments":{"project":"my-project","port":3000}}}
```
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"fmt"
"log"
)
func (c *CaddyClient) AddBasicAuth(domain, username, password string) error {
log.Printf("[WARNING] Password for user %s on domain %s is being transmitted in plaintext through MCP", username, domain)
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
handle, _ := routeMap["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
if len(subRoutes) > 0 {
mainRoute, _ := subRoutes[0].(map[string]interface{})
handlers, _ := mainRoute["handle"].([]interface{})
// Check if auth already exists
var authIdx = -1
for idx, handler := range handlers {
hm, _ := handler.(map[string]interface{})
if hm["handler"] == "authentication" {
authIdx = idx
break
}
}
// WARNING: Passwords are transmitted in plaintext through MCP protocol.
// This is an architectural limitation of the Model Context Protocol.
// The password is stored hashed in Caddy (bcrypt), but travels
// unencrypted from the MCP client to this server.
newAccount := map[string]interface{}{
"username": username,
"password": password,
}
if authIdx >= 0 {
// Add to existing auth
authHandler, _ := handlers[authIdx].(map[string]interface{})
providers, _ := authHandler["providers"].(map[string]interface{})
httpBasic, _ := providers["http_basic"].(map[string]interface{})
accounts, _ := httpBasic["accounts"].([]interface{})
accounts = append(accounts, newAccount)
httpBasic["accounts"] = accounts
providers["http_basic"] = httpBasic
authHandler["providers"] = providers
handlers[authIdx] = authHandler
} else {
// Create new auth handler
authHandler := map[string]interface{}{
"handler": "authentication",
"providers": map[string]interface{}{
"http_basic": map[string]interface{}{
"accounts": []interface{}{newAccount},
"hash": map[string]interface{}{
"algorithm": "bcrypt",
},
},
},
}
handlers = append([]interface{}{authHandler}, handlers...)
}
mainRoute["handle"] = handlers
subRoutes[0] = mainRoute
handlerMap["routes"] = subRoutes
handle[i] = handlerMap
routeMap["handle"] = handle
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
}
}
}
return fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) RemoveBasicAuth(domain string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
handle, _ := routeMap["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
if len(subRoutes) > 0 {
mainRoute, _ := subRoutes[0].(map[string]interface{})
handlers, _ := mainRoute["handle"].([]interface{})
var newHandlers []interface{}
for _, handler := range handlers {
hm, _ := handler.(map[string]interface{})
if hm["handler"] != "authentication" {
newHandlers = append(newHandlers, handler)
}
}
mainRoute["handle"] = newHandlers
subRoutes[0] = mainRoute
handlerMap["routes"] = subRoutes
handle[i] = handlerMap
routeMap["handle"] = handle
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
}
}
}
return fmt.Errorf("site not found: %s", domain)
}
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
# Initialize go module if needed
if [ ! -f go.sum ]; then
echo "📦 Initializing Go module..."
go mod tidy
fi
# Build
echo "🔨 Building caddy-mcp..."
go build -o caddy-mcp .
echo "✅ Build complete: ./caddy-mcp"
echo ""
echo "Usage:"
echo " ./caddy-mcp"
BIN
View File
Binary file not shown.
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type CaddyClient struct {
baseURL string
client *http.Client
}
func NewCaddyClient(host string) *CaddyClient {
if host == "" {
host = "localhost:2019"
}
return &CaddyClient{
baseURL: "http://" + host,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *CaddyClient) doRequest(method, path string, body []byte) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.client.Do(req)
}
func (c *CaddyClient) GetConfig() (map[string]interface{}, error) {
resp, err := c.doRequest("GET", "/config/", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
var config map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
return nil, err
}
return config, nil
}
func (c *CaddyClient) SetConfig(path string, value interface{}) error {
data, _ := json.Marshal(value)
resp, err := c.doRequest("PUT", "/config/"+path, data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *CaddyClient) DeleteConfig(path string) error {
resp, err := c.doRequest("DELETE", "/config/"+path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *CaddyClient) PostConfig(path string, value interface{}) error {
data, _ := json.Marshal(value)
resp, err := c.doRequest("POST", "/config/"+path, data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *CaddyClient) LoadConfig(config interface{}) error {
data, _ := json.Marshal(config)
resp, err := c.doRequest("POST", "/load", data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *CaddyClient) Stop() error {
resp, err := c.doRequest("GET", "/stop", nil)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func (c *CaddyClient) GetConfigAtPath(path string) (interface{}, error) {
resp, err := c.doRequest("GET", "/config/"+path, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, nil
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
var result interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *CaddyClient) AdaptCaddyfile(caddyfile string) (map[string]interface{}, error) {
resp, err := c.doRequest("POST", "/adapt", []byte(caddyfile))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("adapt error %d: %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *CaddyClient) ValidateConfig(config map[string]interface{}) error {
data, _ := json.Marshal(config)
resp, err := c.doRequest("POST", "/validate", data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("validation failed: %s", string(body))
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package main
import "fmt"
func (c *CaddyClient) AddFileServer(domain, root string, browse bool) error {
route := map[string]interface{}{
"handle": []interface{}{
map[string]interface{}{
"handler": "vars",
"root": root,
},
map[string]interface{}{
"handler": "file_server",
"browse": browse,
"hide": []string{".git", ".env"},
},
},
"match": []interface{}{map[string]interface{}{"host": []string{domain}}},
"terminal": true,
}
config, _ := c.GetConfig()
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName := range servers {
routesPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
routes, _ := c.GetConfigAtPath(routesPath)
if routes != nil {
routesList, _ := routes.([]interface{})
routesList = append(routesList, route)
return c.SetConfig(routesPath, routesList)
}
}
return c.SetConfig("apps/http/servers/srv0/routes", []interface{}{route})
}
func (c *CaddyClient) UpdateFileServerRoot(domain, root string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
handle, _ := routeMap["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "vars" {
handlerMap["root"] = root
handle[i] = handlerMap
routeMap["handle"] = handle
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
}
}
return fmt.Errorf("file server not found for: %s", domain)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/lumbridge-corp/caddy-mcp-server
go 1.26
View File
+305
View File
@@ -0,0 +1,305 @@
package main
import (
"encoding/json"
"fmt"
"os"
)
// Site Management Handlers
func (s *MCPServer) handleListSites(id interface{}) {
sites, err := s.client.ListSites()
if err != nil {
s.sendError(id, -32000, err.Error())
return
}
var output string
for _, site := range sites {
domain, _ := site["domain"].(string)
upstreams := s.client.extractUpstreams(site["route"].(map[string]interface{}))
output += fmt.Sprintf("📍 %s\n", domain)
if len(upstreams) > 0 {
output += fmt.Sprintf(" → %v\n", upstreams)
}
if s.client.hasBasicAuth(site["route"].(map[string]interface{})) {
output += " 🔒 Auth enabled\n"
}
output += "\n"
}
if len(sites) == 0 {
output = "No sites configured\n"
}
s.sendTextResult(id, output)
}
func (s *MCPServer) handleGetSite(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
site, err := s.client.GetSite(domain)
if err != nil {
s.sendError(id, -32000, err.Error())
return
}
output := fmt.Sprintf("Domain: %s\n", domain)
output += fmt.Sprintf("Server: %s\n", site["server"])
output += fmt.Sprintf("Upstreams: %v\n", site["upstreams"])
output += fmt.Sprintf("Auth: %v\n", site["has_auth"])
handlers, _ := json.MarshalIndent(site["handlers"], "", " ")
output += fmt.Sprintf("Handlers: %s\n", string(handlers))
s.sendTextResult(id, output)
}
func (s *MCPServer) handleAddSite(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := validateDomain(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
upstream, _ := args["upstream"].(string)
if err := s.client.AddSite(domain, upstream); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added %s → %s", domain, upstream))
}
func (s *MCPServer) handleRemoveSite(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := s.client.RemoveSite(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Removed %s", domain))
}
func (s *MCPServer) handleUpdateUpstream(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
upstream, _ := args["upstream"].(string)
if err := s.client.UpdateUpstream(domain, upstream); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Updated %s → %s", domain, upstream))
}
// Path Route Handlers
func (s *MCPServer) handleAddPathRoute(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := validateDomain(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
path, _ := args["path"].(string)
if err := validatePath(path); err != nil {
s.sendError(id, -32000, err.Error())
return
}
upstream, _ := args["upstream"].(string)
if err := s.client.AddPathRoute(domain, path, upstream); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added %s%s/* → %s", domain, path, upstream))
}
func (s *MCPServer) handleRemovePathRoute(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := validateDomain(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
path, _ := args["path"].(string)
if err := validatePath(path); err != nil {
s.sendError(id, -32000, err.Error())
return
}
if err := s.client.RemovePathRoute(domain, path); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Removed %s%s/*", domain, path))
}
// Auth Handlers
func (s *MCPServer) handleAddBasicAuth(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
username, _ := args["username"].(string)
password, _ := args["password"].(string)
if err := s.client.AddBasicAuth(domain, username, password); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added basic auth for %s (user: %s)", domain, username))
}
func (s *MCPServer) handleRemoveBasicAuth(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := s.client.RemoveBasicAuth(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Removed basic auth from %s", domain))
}
// Header Handlers
func (s *MCPServer) handleAddRequestHeader(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := validateDomain(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
header, _ := args["header"].(string)
if err := validateHeaderName(header); err != nil {
s.sendError(id, -32000, err.Error())
return
}
value, _ := args["value"].(string)
if err := s.client.AddHeaderUp(domain, header, value); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added request header %s: %s for %s", header, value, domain))
}
func (s *MCPServer) handleAddResponseHeader(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
if err := validateDomain(domain); err != nil {
s.sendError(id, -32000, err.Error())
return
}
header, _ := args["header"].(string)
if err := validateHeaderName(header); err != nil {
s.sendError(id, -32000, err.Error())
return
}
value, _ := args["value"].(string)
if err := s.client.AddHeaderDown(domain, header, value); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added response header %s: %s for %s", header, value, domain))
}
// File Server Handlers
func (s *MCPServer) handleAddFileServer(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
root, _ := args["root"].(string)
browse, _ := args["browse"].(bool)
if err := s.client.AddFileServer(domain, root, browse); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added file server %s → %s (browse: %v)", domain, root, browse))
}
func (s *MCPServer) handleUpdateFileRoot(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
root, _ := args["root"].(string)
if err := s.client.UpdateFileServerRoot(domain, root); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Updated %s root → %s", domain, root))
}
// Review Environment Handler
func (s *MCPServer) handleAddWipEnvironment(id interface{}, args map[string]interface{}) {
project, _ := args["project"].(string)
portFloat, _ := args["port"].(float64)
port := int(portFloat)
reviewDomain := os.Getenv("REVIEW_DOMAIN")
if reviewDomain == "" {
reviewDomain = "review.example.com"
}
path := fmt.Sprintf("/%s/wip", project)
upstream := fmt.Sprintf("localhost:%d", port)
if err := s.client.AddPathRoute(reviewDomain, path, upstream); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Added WIP https://%s%s/* → %s", reviewDomain, path, upstream))
}
// Config Handlers
func (s *MCPServer) handleExportConfig(id interface{}) {
config, err := s.client.GetConfig()
if err != nil {
s.sendError(id, -32000, err.Error())
return
}
data, _ := json.MarshalIndent(config, "", " ")
s.sendTextResult(id, string(data))
}
func (s *MCPServer) handleAdaptCaddyfile(id interface{}, args map[string]interface{}) {
caddyfile, _ := args["caddyfile"].(string)
result, err := s.client.AdaptCaddyfile(caddyfile)
if err != nil {
s.sendError(id, -32000, err.Error())
return
}
data, _ := json.MarshalIndent(result, "", " ")
s.sendTextResult(id, string(data))
}
func (s *MCPServer) handleValidateConfig(id interface{}, args map[string]interface{}) {
configArg, _ := args["config"].(map[string]interface{})
if err := s.client.ValidateConfig(configArg); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, "✅ Configuration is valid")
}
// Control Handlers
func (s *MCPServer) handleCaddyStatus(id interface{}) {
sites, err := s.client.ListSites()
if err != nil {
s.sendTextResult(id, fmt.Sprintf("❌ Caddy unreachable: %v", err))
return
}
s.sendTextResult(id, fmt.Sprintf("✅ Caddy running (%d sites configured)", len(sites)))
}
func (s *MCPServer) handleStopCaddy(id interface{}) {
if err := s.client.Stop(); err != nil {
s.sendError(id, -32000, err.Error())
return
}
s.sendTextResult(id, "✅ Caddy stopped")
}
// Helper functions
func (s *MCPServer) sendTextResult(id interface{}, text string) {
s.sendResult(id, map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": text},
},
})
}
func (s *MCPServer) sendResult(id interface{}, result interface{}) {
resp := map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": result,
}
data, _ := json.Marshal(resp)
fmt.Println(string(data))
}
func (s *MCPServer) sendError(id interface{}, code int, message string) {
resp := map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"error": map[string]interface{}{
"code": code,
"message": message,
},
}
data, _ := json.Marshal(resp)
fmt.Println(string(data))
}
+125
View File
@@ -0,0 +1,125 @@
package main
import "fmt"
func (c *CaddyClient) AddHeaderUp(domain, header, value string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
c.addHeaderToRoute(routeMap, header, value, "request")
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
return fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) AddHeaderDown(domain, header, value string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
c.addHeaderToRoute(routeMap, header, value, "response")
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
return fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) addHeaderToRoute(route map[string]interface{}, header, value, direction string) {
handle, _ := route["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
if len(subRoutes) > 0 {
mainRoute, _ := subRoutes[0].(map[string]interface{})
handlers, _ := mainRoute["handle"].([]interface{})
headersHandler := map[string]interface{}{
"handler": "headers",
}
if direction == "request" {
headersHandler["request"] = map[string]interface{}{
"set": map[string]interface{}{
header: []string{value},
},
}
} else {
headersHandler["response"] = map[string]interface{}{
"set": map[string]interface{}{
header: []string{value},
},
}
}
// Find reverse_proxy and insert headers before it
for j, handler := range handlers {
hm, _ := handler.(map[string]interface{})
if hm["handler"] == "reverse_proxy" {
// Insert before reverse_proxy
newHandlers := append(handlers[:j], headersHandler)
newHandlers = append(newHandlers, handlers[j:]...)
mainRoute["handle"] = newHandlers
subRoutes[0] = mainRoute
handlerMap["routes"] = subRoutes
handle[i] = handlerMap
route["handle"] = handle
return
}
}
}
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"fmt"
"strings"
)
func (c *CaddyClient) AddPathRoute(domain, path, upstream string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
newRoute := map[string]interface{}{
"group": "group1",
"handle": []interface{}{
map[string]interface{}{
"handler": "subroute",
"routes": []interface{}{
map[string]interface{}{
"handle": []interface{}{
map[string]interface{}{
"handler": "reverse_proxy",
"upstreams": []interface{}{map[string]interface{}{"dial": upstream}},
},
},
},
},
},
},
"match": []interface{}{
map[string]interface{}{
"path": []string{path + "/*"},
},
},
}
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
handle, _ := routeMap["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
subRoutes = append(subRoutes, newRoute)
handlerMap["routes"] = subRoutes
handle[i] = handlerMap
routeMap["handle"] = handle
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
}
}
return fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) RemovePathRoute(domain, path string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for routeIdx, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
handle, _ := routeMap["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
var newSubRoutes []interface{}
found := false
for _, sr := range subRoutes {
srMap, ok := sr.(map[string]interface{})
if !ok {
newSubRoutes = append(newSubRoutes, sr)
continue
}
srMatches, _ := srMap["match"].([]interface{})
for _, srm := range srMatches {
srmMap, _ := srm.(map[string]interface{})
paths, _ := srmMap["path"].([]interface{})
for _, p := range paths {
ps, _ := p.(string)
if strings.HasPrefix(ps, path) {
found = true
break
}
}
}
if !found {
newSubRoutes = append(newSubRoutes, sr)
}
}
if found {
handlerMap["routes"] = newSubRoutes
handle[i] = handlerMap
routeMap["handle"] = handle
routes[routeIdx] = routeMap
configPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(configPath, routes)
}
}
}
}
}
}
}
}
return fmt.Errorf("site or path not found: %s/%s", domain, path)
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"fmt"
)
func (s *MCPServer) handleGetSiteRoutes(id interface{}, args map[string]interface{}) {
domain, _ := args["domain"].(string)
config, err := s.client.GetConfig()
if err != nil {
s.sendError(id, -32000, err.Error())
return
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
var output string
for _, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for _, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
output = fmt.Sprintf("Routes for %s:\n\n", domain)
data, _ := json.MarshalIndent(routeMap, "", " ")
output += string(data)
s.sendTextResult(id, output)
return
}
}
}
}
}
s.sendError(id, -32000, fmt.Sprintf("site not found: %s", domain))
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"bufio"
"encoding/json"
"os"
)
type MCPServer struct {
client *CaddyClient
}
func main() {
caddyHost := os.Getenv("CADDY_HOST")
if caddyHost == "" {
caddyHost = "localhost:2019"
}
client := NewCaddyClient(caddyHost)
server := &MCPServer{client: client}
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(line), &req); err != nil {
server.sendError(nil, -32700, "Parse error")
continue
}
server.handleRequest(req)
}
}
func (s *MCPServer) handleRequest(req map[string]interface{}) {
method, _ := req["method"].(string)
id := req["id"]
switch method {
case "initialize":
s.sendResult(id, map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
"serverInfo": map[string]interface{}{
"name": "caddy-mcp",
"version": "2.0.0",
},
})
case "tools/list":
s.sendResult(id, map[string]interface{}{"tools": getAllTools()})
case "tools/call":
params, _ := req["params"].(map[string]interface{})
toolName, _ := params["name"].(string)
args, _ := params["arguments"].(map[string]interface{})
s.handleToolCall(id, toolName, args)
default:
s.sendError(id, -32601, "Method not found")
}
}
func getAllTools() []interface{} {
return []interface{}{
// Site management
map[string]interface{}{
"name": "list_sites",
"description": "List all configured Caddy sites with their upstreams",
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
},
map[string]interface{}{
"name": "get_site",
"description": "Get detailed configuration for a specific site",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"domain": map[string]interface{}{"type": "string", "description": "Domain name"}},
"required": []string{"domain"},
},
},
map[string]interface{}{
"name": "get_site_routes",
"description": "Get all routes (including path-based) for a site",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"domain": map[string]interface{}{"type": "string", "description": "Domain name"}},
"required": []string{"domain"},
},
},
map[string]interface{}{
"name": "add_site",
"description": "Add a new site with reverse proxy",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"upstream": map[string]interface{}{"type": "string", "description": "Upstream address (e.g., localhost:3000)"},
},
"required": []string{"domain", "upstream"},
},
},
map[string]interface{}{
"name": "remove_site",
"description": "Remove a site configuration",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"domain": map[string]interface{}{"type": "string", "description": "Domain to remove"}},
"required": []string{"domain"},
},
},
map[string]interface{}{
"name": "update_upstream",
"description": "Update the upstream address for a site",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"upstream": map[string]interface{}{"type": "string", "description": "New upstream address"},
},
"required": []string{"domain", "upstream"},
},
},
// Path-based routing
map[string]interface{}{
"name": "add_path_route",
"description": "Add a path-based route to a site (e.g., /api/* → localhost:3000)",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"path": map[string]interface{}{"type": "string", "description": "Path pattern (e.g., /api)"},
"upstream": map[string]interface{}{"type": "string", "description": "Upstream address"},
},
"required": []string{"domain", "path", "upstream"},
},
},
map[string]interface{}{
"name": "remove_path_route",
"description": "Remove a path-based route from a site",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"path": map[string]interface{}{"type": "string", "description": "Path pattern to remove"},
},
"required": []string{"domain", "path"},
},
},
// Authentication
map[string]interface{}{
"name": "add_basic_auth",
"description": "Add basic authentication to a site",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"username": map[string]interface{}{"type": "string", "description": "Username"},
"password": map[string]interface{}{"type": "string", "description": "Password (will be hashed)"},
},
"required": []string{"domain", "username", "password"},
},
},
map[string]interface{}{
"name": "remove_basic_auth",
"description": "Remove basic authentication from a site",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"domain": map[string]interface{}{"type": "string", "description": "Domain name"}},
"required": []string{"domain"},
},
},
// Headers
map[string]interface{}{
"name": "add_request_header",
"description": "Add a request header sent to upstream",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"header": map[string]interface{}{"type": "string", "description": "Header name"},
"value": map[string]interface{}{"type": "string", "description": "Header value"},
},
"required": []string{"domain", "header", "value"},
},
},
map[string]interface{}{
"name": "add_response_header",
"description": "Add a response header sent to client",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"header": map[string]interface{}{"type": "string", "description": "Header name"},
"value": map[string]interface{}{"type": "string", "description": "Header value"},
},
"required": []string{"domain", "header", "value"},
},
},
// File server
map[string]interface{}{
"name": "add_file_server",
"description": "Add a static file server for a domain",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"root": map[string]interface{}{"type": "string", "description": "Root directory path"},
"browse": map[string]interface{}{"type": "boolean", "description": "Enable directory browsing"},
},
"required": []string{"domain", "root"},
},
},
map[string]interface{}{
"name": "update_file_root",
"description": "Update the root directory for a file server",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"domain": map[string]interface{}{"type": "string", "description": "Domain name"},
"root": map[string]interface{}{"type": "string", "description": "New root directory"},
},
"required": []string{"domain", "root"},
},
},
// Review environment helpers
map[string]interface{}{
"name": "add_wip_environment",
"description": "Add a WIP review environment (e.g., /project/wip/* → port)",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"project": map[string]interface{}{"type": "string", "description": "Project name (e.g., my-project, frontend)"},
"port": map[string]interface{}{"type": "number", "description": "Port number"},
},
"required": []string{"project", "port"},
},
},
// Configuration
map[string]interface{}{
"name": "export_config",
"description": "Export full Caddy configuration as JSON",
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
},
map[string]interface{}{
"name": "adapt_caddyfile",
"description": "Convert Caddyfile format to JSON",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"caddyfile": map[string]interface{}{"type": "string", "description": "Caddyfile content"}},
"required": []string{"caddyfile"},
},
},
// Server control
map[string]interface{}{
"name": "caddy_status",
"description": "Check Caddy server health and status",
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
},
map[string]interface{}{
"name": "validate_config",
"description": "Validate a configuration without applying it",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"config": map[string]interface{}{"type": "object", "description": "Configuration to validate"}},
"required": []string{"config"},
},
},
map[string]interface{}{
"name": "stop_caddy",
"description": "Stop the Caddy server gracefully",
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
},
}
}
+163
View File
@@ -0,0 +1,163 @@
package main
import "fmt"
func (c *CaddyClient) AddSite(domain, upstream string) error {
route := map[string]interface{}{
"handle": []interface{}{
map[string]interface{}{
"handler": "subroute",
"routes": []interface{}{
map[string]interface{}{
"handle": []interface{}{
map[string]interface{}{
"handler": "reverse_proxy",
"upstreams": []interface{}{map[string]interface{}{"dial": upstream}},
},
},
},
},
},
},
"match": []interface{}{map[string]interface{}{"host": []string{domain}}},
"terminal": true,
}
config, _ := c.GetConfig()
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName := range servers {
routesPath := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
routes, _ := c.GetConfigAtPath(routesPath)
if routes != nil {
routesList, _ := routes.([]interface{})
routesList = append(routesList, route)
return c.SetConfig(routesPath, routesList)
}
}
return c.SetConfig("apps/http/servers/srv0/routes", []interface{}{route})
}
func (c *CaddyClient) RemoveSite(domain string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
var newRoutes []interface{}
found := false
for _, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
found = true
continue
}
}
}
newRoutes = append(newRoutes, route)
}
if found {
path := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(path, newRoutes)
}
}
return fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) UpdateUpstream(domain, upstream string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for srvName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for i, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
newRoute := c.updateUpstreamInRoute(routeMap, upstream)
routes[i] = newRoute
path := fmt.Sprintf("apps/http/servers/%s/routes", srvName)
return c.SetConfig(path, routes)
}
}
}
}
}
return fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) updateUpstreamInRoute(route map[string]interface{}, upstream string) map[string]interface{} {
handle, _ := route["handle"].([]interface{})
for i, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "reverse_proxy" {
handlerMap["upstreams"] = []interface{}{map[string]interface{}{"dial": upstream}}
handle[i] = handlerMap
route["handle"] = handle
return route
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
for j, sr := range subRoutes {
srMap, ok := sr.(map[string]interface{})
if !ok {
continue
}
srHandlers, _ := srMap["handle"].([]interface{})
for k, srH := range srHandlers {
srHMap, ok := srH.(map[string]interface{})
if !ok {
continue
}
if srHMap["handler"] == "reverse_proxy" {
srHMap["upstreams"] = []interface{}{map[string]interface{}{"dial": upstream}}
srHandlers[k] = srHMap
srMap["handle"] = srHandlers
subRoutes[j] = srMap
handlerMap["routes"] = subRoutes
handle[i] = handlerMap
route["handle"] = handle
return route
}
}
}
}
}
return route
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"fmt"
)
func (c *CaddyClient) ListSites() ([]map[string]interface{}, error) {
config, err := c.GetConfig()
if err != nil {
return nil, err
}
return c.extractSitesFromConfig(config)
}
func (c *CaddyClient) extractSitesFromConfig(config map[string]interface{}) ([]map[string]interface{}, error) {
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
var sites []map[string]interface{}
for serverName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for _, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
domain, _ := hosts[0].(string)
site := map[string]interface{}{
"domain": domain,
"server": serverName,
"route": routeMap,
}
sites = append(sites, site)
}
}
}
}
return sites, nil
}
func (c *CaddyClient) GetSite(domain string) (map[string]interface{}, error) {
config, err := c.GetConfig()
if err != nil {
return nil, err
}
apps, _ := config["apps"].(map[string]interface{})
httpApps, _ := apps["http"].(map[string]interface{})
servers, _ := httpApps["servers"].(map[string]interface{})
for serverName, server := range servers {
srv, _ := server.(map[string]interface{})
routes, _ := srv["routes"].([]interface{})
for _, route := range routes {
routeMap, _ := route.(map[string]interface{})
matches, _ := routeMap["match"].([]interface{})
for _, match := range matches {
matchMap, _ := match.(map[string]interface{})
hosts, _ := matchMap["host"].([]interface{})
if len(hosts) > 0 {
d, _ := hosts[0].(string)
if d == domain {
site := map[string]interface{}{
"domain": domain,
"server": serverName,
"route": routeMap,
"handlers": c.extractHandlers(routeMap),
"upstreams": c.extractUpstreams(routeMap),
"has_auth": c.hasBasicAuth(routeMap),
}
return site, nil
}
}
}
}
}
return nil, fmt.Errorf("site not found: %s", domain)
}
func (c *CaddyClient) extractHandlers(route map[string]interface{}) []map[string]interface{} {
var handlers []map[string]interface{}
handle, _ := route["handle"].([]interface{})
for _, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
handlers = append(handlers, handlerMap)
}
return handlers
}
func (c *CaddyClient) extractUpstreams(route map[string]interface{}) []string {
var upstreams []string
handle, _ := route["handle"].([]interface{})
for _, h := range handle {
handlerMap, ok := h.(map[string]interface{})
if !ok {
continue
}
if handlerMap["handler"] == "reverse_proxy" {
ups, _ := handlerMap["upstreams"].([]interface{})
for _, u := range ups {
upMap, ok := u.(map[string]interface{})
if ok {
dial, _ := upMap["dial"].(string)
if dial != "" {
upstreams = append(upstreams, dial)
}
}
}
}
if handlerMap["handler"] == "subroute" {
subRoutes, _ := handlerMap["routes"].([]interface{})
for _, sr := range subRoutes {
srMap, ok := sr.(map[string]interface{})
if ok {
srHandlers, _ := srMap["handle"].([]interface{})
for _, srH := range srHandlers {
srHMap, ok := srH.(map[string]interface{})
if !ok {
continue
}
if srHMap["handler"] == "reverse_proxy" {
ups, _ := srHMap["upstreams"].([]interface{})
for _, u := range ups {
upMap, ok := u.(map[string]interface{})
if ok {
dial, _ := upMap["dial"].(string)
if dial != "" {
upstreams = append(upstreams, dial)
}
}
}
}
}
}
}
}
}
return upstreams
}
func (c *CaddyClient) hasBasicAuth(route map[string]interface{}) bool {
handlers := c.extractHandlers(route)
for _, h := range handlers {
if h["handler"] == "authentication" {
return true
}
if h["handler"] == "subroute" {
subRoutes, _ := h["routes"].([]interface{})
for _, sr := range subRoutes {
srMap, ok := sr.(map[string]interface{})
if !ok {
continue
}
srHandlers, _ := srMap["handle"].([]interface{})
for _, srH := range srHandlers {
srHMap, ok := srH.(map[string]interface{})
if !ok {
continue
}
if srHMap["handler"] == "authentication" {
return true
}
}
}
}
}
return false
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Test script for caddy-mcp
echo 'Testing list_sites...'
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_sites","arguments":{}}}' | ./caddy-mcp
echo ''
echo 'Testing caddy_status...'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"caddy_status","arguments":{}}}' | ./caddy-mcp
+51
View File
@@ -0,0 +1,51 @@
package main
import "fmt"
func (s *MCPServer) handleToolCall(id interface{}, toolName string, args map[string]interface{}) {
switch toolName {
case "list_sites":
s.handleListSites(id)
case "get_site":
s.handleGetSite(id, args)
case "get_site_routes":
s.handleGetSiteRoutes(id, args)
case "add_site":
s.handleAddSite(id, args)
case "remove_site":
s.handleRemoveSite(id, args)
case "update_upstream":
s.handleUpdateUpstream(id, args)
case "add_path_route":
s.handleAddPathRoute(id, args)
case "remove_path_route":
s.handleRemovePathRoute(id, args)
case "add_basic_auth":
s.handleAddBasicAuth(id, args)
case "remove_basic_auth":
s.handleRemoveBasicAuth(id, args)
case "add_request_header":
s.handleAddRequestHeader(id, args)
case "add_response_header":
s.handleAddResponseHeader(id, args)
case "add_file_server":
s.handleAddFileServer(id, args)
case "update_file_root":
s.handleUpdateFileRoot(id, args)
case "add_wip_environment":
s.handleAddWipEnvironment(id, args)
case "export_config":
s.handleExportConfig(id)
case "adapt_caddyfile":
s.handleAdaptCaddyfile(id, args)
case "validate_config":
s.handleValidateConfig(id, args)
case "caddy_status":
s.handleCaddyStatus(id)
case "stop_caddy":
s.handleStopCaddy(id)
default:
s.sendError(id, -32601, fmt.Sprintf("Unknown tool: %s", toolName))
}
}
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"crypto/rand"
"encoding/base64"
)
func generateSalt() (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
return base64.RawStdEncoding.EncodeToString(salt), nil
}
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
)
func validateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain cannot be empty")
}
domainRegex := regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
if !domainRegex.MatchString(domain) {
return fmt.Errorf("invalid domain format: %s", domain)
}
return nil
}
func validatePath(path string) error {
if path == "" {
return fmt.Errorf("path cannot be empty")
}
if strings.Contains(path, "..") {
return fmt.Errorf("path cannot contain parent directory references (..)")
}
if !strings.HasPrefix(path, "/") {
return fmt.Errorf("path must start with /")
}
return nil
}
func validatePort(port string) error {
if port == "" {
return fmt.Errorf("port cannot be empty")
}
p, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("invalid port number: %s", port)
}
if p < 1 || p > 65535 {
return fmt.Errorf("port must be between 1 and 65535, got: %d", p)
}
return nil
}
func validateHeaderName(name string) error {
if name == "" {
return fmt.Errorf("header name cannot be empty")
}
headerRegex := regexp.MustCompile("^[a-zA-Z0-9!#$%&'*+\\-\\.^_`\\|~]+$")
if !headerRegex.MatchString(name) {
return fmt.Errorf("invalid header name format: %s (must be valid HTTP token characters only)", name)
}
return nil
}