commit 2a3107f9e27ac74aeb13f70c7385d6191c4cb105 Author: Karti Date: Fri Apr 10 21:57:20 2026 -0700 Initial commit: Caddy MCP Server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50fe530 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4910f30 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8be526e --- /dev/null +++ b/README.md @@ -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 & +./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}}} +``` diff --git a/auth_ops.go b/auth_ops.go new file mode 100644 index 0000000..5accaf6 --- /dev/null +++ b/auth_ops.go @@ -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) +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8080d68 --- /dev/null +++ b/build.sh @@ -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" diff --git a/caddy-mcp-server b/caddy-mcp-server new file mode 100755 index 0000000..82caf99 Binary files /dev/null and b/caddy-mcp-server differ diff --git a/client.go b/client.go new file mode 100644 index 0000000..c97b7db --- /dev/null +++ b/client.go @@ -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 +} diff --git a/fileserver.go b/fileserver.go new file mode 100644 index 0000000..e185d9a --- /dev/null +++ b/fileserver.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f78468d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lumbridge-corp/caddy-mcp-server + +go 1.26 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..3f65bc2 --- /dev/null +++ b/handlers.go @@ -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)) +} diff --git a/headers.go b/headers.go new file mode 100644 index 0000000..40f0028 --- /dev/null +++ b/headers.go @@ -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 + } + } + } + } + } +} diff --git a/path_routes.go b/path_routes.go new file mode 100644 index 0000000..af453f8 --- /dev/null +++ b/path_routes.go @@ -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) +} diff --git a/routes_handler.go b/routes_handler.go new file mode 100644 index 0000000..a2e42b9 --- /dev/null +++ b/routes_handler.go @@ -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)) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..9864082 --- /dev/null +++ b/server.go @@ -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{}{}}, + }, + } +} diff --git a/site_modify.go b/site_modify.go new file mode 100644 index 0000000..d347a1b --- /dev/null +++ b/site_modify.go @@ -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 +} diff --git a/site_ops.go b/site_ops.go new file mode 100644 index 0000000..d866a16 --- /dev/null +++ b/site_ops.go @@ -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 +} diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..157d55f --- /dev/null +++ b/test.sh @@ -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 diff --git a/tool_router.go b/tool_router.go new file mode 100644 index 0000000..87bd35a --- /dev/null +++ b/tool_router.go @@ -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)) + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..399ac70 --- /dev/null +++ b/utils.go @@ -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 +} diff --git a/validators.go b/validators.go new file mode 100644 index 0000000..99a4cc5 --- /dev/null +++ b/validators.go @@ -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 +}