From 525efc0b6722edff4f3e347552d4fedd210d6fc6 Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 21:30:45 +0200 Subject: [PATCH 1/8] feat: add OpenAPI 3.1 spec for all acme-dns endpoints --- openapi.json | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 openapi.json diff --git a/openapi.json b/openapi.json new file mode 100644 index 00000000..10e3ae8a --- /dev/null +++ b/openapi.json @@ -0,0 +1,188 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "acme-dns", + "version": "1.0.0", + "description": "Simplified DNS server with HTTP API for ACME DNS challenge automation and general DNS record management." + }, + "components": { + "securitySchemes": { + "ApiUser": { + "type": "apiKey", + "in": "header", + "name": "X-Api-User", + "description": "UUIDv4 username obtained from /register" + }, + "ApiKey": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key", + "description": "40-character base64url password obtained from /register" + }, + "AdminBearer": { + "type": "http", + "scheme": "bearer", + "description": "Admin token configured in [api.admin].token" + } + }, + "schemas": { + "Registration": { + "type": "object", + "properties": { + "username": { "type": "string", "format": "uuid" }, + "password": { "type": "string" }, + "fulldomain": { "type": "string" }, + "subdomain": { "type": "string" }, + "allowfrom": { "type": "array", "items": { "type": "string" } } + } + }, + "TxtUpdate": { + "type": "object", + "required": ["subdomain", "txt"], + "properties": { + "subdomain": { "type": "string", "description": "UUID subdomain from registration" }, + "txt": { "type": "string", "minLength": 43, "maxLength": 43, "description": "ACME challenge token (exactly 43 characters)" } + } + }, + "DNSRecord": { + "type": "object", + "required": ["name", "type", "value"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "description": "Fully qualified domain name, e.g. sub.example.com" }, + "type": { "type": "string", "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "PTR"] }, + "value": { "type": "string" }, + "ttl": { "type": "integer", "minimum": 1, "maximum": 86400, "default": 300 }, + "created": { "type": "integer", "description": "Unix timestamp" } + } + }, + "Error": { + "type": "object", + "properties": { + "error": { "type": "string" } + } + } + } + }, + "paths": { + "/register": { + "post": { + "summary": "Register a new subdomain", + "description": "Creates a new user account with a UUID subdomain for ACME DNS challenge delegation. Optionally restrict access to specific IP CIDRs.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowfrom": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of CIDR ranges allowed to use this account, e.g. [\"192.168.1.0/24\"]" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Account created", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Registration" } } } + }, + "400": { "description": "Invalid CIDR or malformed JSON", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/update": { + "post": { + "summary": "Update ACME TXT record", + "description": "Updates the TXT record for your subdomain with a new ACME challenge token. Requires X-Api-User and X-Api-Key headers from registration.", + "security": [{ "ApiUser": [], "ApiKey": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TxtUpdate" } + } + } + }, + "responses": { + "200": { "description": "TXT record updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "txt": { "type": "string" } } } } } }, + "400": { "description": "Invalid subdomain or TXT value" }, + "401": { "description": "Invalid credentials or IP not allowed" } + } + } + }, + "/health": { + "get": { + "summary": "Health check", + "description": "Returns 200 OK when the server is ready. Use for liveness and readiness probes.", + "responses": { + "200": { "description": "Server is healthy" } + } + } + }, + "/admin/records": { + "get": { + "summary": "List DNS records", + "description": "Returns all managed DNS records. Filter by type and/or name using query parameters.", + "security": [{ "AdminBearer": [] }], + "parameters": [ + { "name": "type", "in": "query", "schema": { "type": "string" }, "description": "Filter by record type, e.g. A" }, + { "name": "name", "in": "query", "schema": { "type": "string" }, "description": "Filter by exact domain name" } + ], + "responses": { + "200": { "description": "List of records", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DNSRecord" } } } } }, + "401": { "description": "Invalid or missing admin token" } + } + }, + "post": { + "summary": "Create DNS record", + "description": "Creates a new managed DNS record. Supported types: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, PTR.", + "security": [{ "AdminBearer": [] }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DNSRecord" } } } + }, + "responses": { + "201": { "description": "Record created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DNSRecord" } } } }, + "400": { "description": "Invalid record type, value, or TTL" }, + "401": { "description": "Invalid or missing admin token" } + } + } + }, + "/admin/records/{id}": { + "put": { + "summary": "Update DNS record", + "description": "Updates an existing managed DNS record by ID.", + "security": [{ "AdminBearer": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DNSRecord" } } } + }, + "responses": { + "200": { "description": "Record updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DNSRecord" } } } }, + "400": { "description": "Invalid record data" }, + "401": { "description": "Invalid or missing admin token" } + } + }, + "delete": { + "summary": "Delete DNS record", + "description": "Deletes a managed DNS record by ID.", + "security": [{ "AdminBearer": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "204": { "description": "Record deleted" }, + "401": { "description": "Invalid or missing admin token" } + } + } + } + } +} From 97cd5479d0d7da3c6895df191a49f6c5bc30a103 Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 21:30:55 +0200 Subject: [PATCH 2/8] feat: add MCP server config loading (file + env vars) --- cmd/acme-dns-mcp/config.go | 35 +++++++++++++++++++++++++ cmd/acme-dns-mcp/main.go | 3 +++ cmd/acme-dns-mcp/mcp_test.go | 50 ++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 cmd/acme-dns-mcp/config.go create mode 100644 cmd/acme-dns-mcp/main.go create mode 100644 cmd/acme-dns-mcp/mcp_test.go diff --git a/cmd/acme-dns-mcp/config.go b/cmd/acme-dns-mcp/config.go new file mode 100644 index 00000000..c8d4c1fd --- /dev/null +++ b/cmd/acme-dns-mcp/config.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + + "github.com/BurntSushi/toml" +) + +type mcpConfig struct { + BaseURL string `toml:"base_url"` + AdminToken string `toml:"admin_token"` + Username string `toml:"username"` + Password string `toml:"password"` +} + +// loadConfig reads from a TOML file (if path non-empty), then overrides with env vars. +func loadConfig(path string) mcpConfig { + var cfg mcpConfig + if path != "" { + _, _ = toml.DecodeFile(path, &cfg) + } + if v := os.Getenv("ACMEDNS_BASE_URL"); v != "" { + cfg.BaseURL = v + } + if v := os.Getenv("ACMEDNS_ADMIN_TOKEN"); v != "" { + cfg.AdminToken = v + } + if v := os.Getenv("ACMEDNS_USERNAME"); v != "" { + cfg.Username = v + } + if v := os.Getenv("ACMEDNS_PASSWORD"); v != "" { + cfg.Password = v + } + return cfg +} diff --git a/cmd/acme-dns-mcp/main.go b/cmd/acme-dns-mcp/main.go new file mode 100644 index 00000000..38dd16da --- /dev/null +++ b/cmd/acme-dns-mcp/main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/cmd/acme-dns-mcp/mcp_test.go b/cmd/acme-dns-mcp/mcp_test.go new file mode 100644 index 00000000..17bd8178 --- /dev/null +++ b/cmd/acme-dns-mcp/mcp_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "testing" +) + +func TestLoadConfigFromEnv(t *testing.T) { + os.Setenv("ACMEDNS_BASE_URL", "https://acmedns.example.com") + os.Setenv("ACMEDNS_ADMIN_TOKEN", "secret-admin") + os.Setenv("ACMEDNS_USERNAME", "user-uuid") + os.Setenv("ACMEDNS_PASSWORD", "user-pass") + defer func() { + os.Unsetenv("ACMEDNS_BASE_URL") + os.Unsetenv("ACMEDNS_ADMIN_TOKEN") + os.Unsetenv("ACMEDNS_USERNAME") + os.Unsetenv("ACMEDNS_PASSWORD") + }() + + cfg := loadConfig("") + if cfg.BaseURL != "https://acmedns.example.com" { + t.Errorf("BaseURL: got %q", cfg.BaseURL) + } + if cfg.AdminToken != "secret-admin" { + t.Errorf("AdminToken: got %q", cfg.AdminToken) + } + if cfg.Username != "user-uuid" { + t.Errorf("Username: got %q", cfg.Username) + } + if cfg.Password != "user-pass" { + t.Errorf("Password: got %q", cfg.Password) + } +} + +func TestLoadConfigFromFile(t *testing.T) { + f, _ := os.CreateTemp("", "mcp-cfg-*.toml") + defer os.Remove(f.Name()) + f.WriteString(` +base_url = "https://local.example.com" +admin_token = "file-admin" +username = "file-user" +password = "file-pass" +`) + f.Close() + + cfg := loadConfig(f.Name()) + if cfg.BaseURL != "https://local.example.com" { + t.Errorf("BaseURL from file: got %q", cfg.BaseURL) + } +} From 58d98eaa9c1c4b4795d2bc6d7a0fd688fbefedc0 Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 21:33:03 +0200 Subject: [PATCH 3/8] feat: add MCP tool definitions and HTTP proxy logic --- cmd/acme-dns-mcp/mcp_test.go | 33 ++++++ cmd/acme-dns-mcp/tools.go | 199 +++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 cmd/acme-dns-mcp/tools.go diff --git a/cmd/acme-dns-mcp/mcp_test.go b/cmd/acme-dns-mcp/mcp_test.go index 17bd8178..13d1aa1b 100644 --- a/cmd/acme-dns-mcp/mcp_test.go +++ b/cmd/acme-dns-mcp/mcp_test.go @@ -1,6 +1,8 @@ package main import ( + "net/http" + "net/http/httptest" "os" "testing" ) @@ -48,3 +50,34 @@ password = "file-pass" t.Errorf("BaseURL from file: got %q", cfg.BaseURL) } } + +func TestToolHealthCheck(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.WriteHeader(http.StatusOK) + } + })) + defer srv.Close() + + cfg := mcpConfig{BaseURL: srv.URL} + result, err := callTool(cfg, "health_check", map[string]interface{}{}) + if err != nil { + t.Fatalf("health_check failed: %v", err) + } + if result["status"] != "ok" { + t.Errorf("expected status ok, got %v", result) + } +} + +func TestToolListTools(t *testing.T) { + tools := listTools() + names := make(map[string]bool) + for _, tool := range tools { + names[tool.Name] = true + } + for _, expected := range []string{"register_subdomain", "update_txt_record", "list_dns_records", "create_dns_record", "update_dns_record", "delete_dns_record", "health_check"} { + if !names[expected] { + t.Errorf("missing tool: %s", expected) + } + } +} diff --git a/cmd/acme-dns-mcp/tools.go b/cmd/acme-dns-mcp/tools.go new file mode 100644 index 00000000..57495224 --- /dev/null +++ b/cmd/acme-dns-mcp/tools.go @@ -0,0 +1,199 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// tool describes one MCP tool +type tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +func listTools() []tool { + return []tool{ + { + Name: "health_check", + Description: "Check if the acme-dns server is reachable and healthy.", + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }, + { + Name: "register_subdomain", + Description: "Register a new subdomain and receive credentials (username, password, subdomain) for ACME DNS challenge delegation.", + InputSchema: json.RawMessage(`{"type":"object","properties":{"allowfrom":{"type":"array","items":{"type":"string"},"description":"Optional CIDR ranges allowed to use this account"}}}`), + }, + { + Name: "update_txt_record", + Description: "Update the ACME TXT challenge record for a registered subdomain. Requires username and password from registration.", + InputSchema: json.RawMessage(`{"type":"object","required":["subdomain","txt"],"properties":{"subdomain":{"type":"string","description":"UUID subdomain from registration"},"txt":{"type":"string","description":"Exactly 43-character ACME challenge token"}}}`), + }, + { + Name: "list_dns_records", + Description: "List all managed DNS records. Optionally filter by type (e.g. A) or name.", + InputSchema: json.RawMessage(`{"type":"object","properties":{"type":{"type":"string"},"name":{"type":"string"}}}`), + }, + { + Name: "create_dns_record", + Description: "Create a new managed DNS record. Supported types: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, PTR.", + InputSchema: json.RawMessage(`{"type":"object","required":["name","type","value"],"properties":{"name":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"},"ttl":{"type":"integer","default":300}}}`), + }, + { + Name: "update_dns_record", + Description: "Update an existing managed DNS record by ID.", + InputSchema: json.RawMessage(`{"type":"object","required":["id","name","type","value"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"},"ttl":{"type":"integer"}}}`), + }, + { + Name: "delete_dns_record", + Description: "Delete a managed DNS record by ID.", + InputSchema: json.RawMessage(`{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}}`), + }, + } +} + +// callTool executes a tool by name and returns the result as a map. +func callTool(cfg mcpConfig, name string, args map[string]interface{}) (map[string]interface{}, error) { + switch name { + case "health_check": + return toolHealthCheck(cfg) + case "register_subdomain": + return toolRegister(cfg, args) + case "update_txt_record": + return toolUpdateTXT(cfg, args) + case "list_dns_records": + return toolListRecords(cfg, args) + case "create_dns_record": + return toolCreateRecord(cfg, args) + case "update_dns_record": + return toolUpdateRecord(cfg, args) + case "delete_dns_record": + return toolDeleteRecord(cfg, args) + } + return nil, fmt.Errorf("unknown tool: %s", name) +} + +func doRequest(cfg mcpConfig, method, path string, body interface{}, headers map[string]string) (map[string]interface{}, int, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + bodyReader = bytes.NewReader(b) + } + req, err := http.NewRequest(method, cfg.BaseURL+path, bodyReader) + if err != nil { + return nil, 0, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + if result == nil { + result = map[string]interface{}{} + } + return result, resp.StatusCode, nil +} + +func toolHealthCheck(cfg mcpConfig) (map[string]interface{}, error) { + _, status, err := doRequest(cfg, "GET", "/health", nil, nil) + if err != nil { + return nil, err + } + if status == http.StatusOK { + return map[string]interface{}{"status": "ok"}, nil + } + return map[string]interface{}{"status": "unhealthy", "code": status}, nil +} + +func toolRegister(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { + result, _, err := doRequest(cfg, "POST", "/register", args, nil) + return result, err +} + +func toolUpdateTXT(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { + if cfg.Username == "" || cfg.Password == "" { + return map[string]interface{}{"error": "username and password not configured"}, nil + } + headers := map[string]string{ + "X-Api-User": cfg.Username, + "X-Api-Key": cfg.Password, + } + result, _, err := doRequest(cfg, "POST", "/update", args, headers) + return result, err +} + +func toolListRecords(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { + if cfg.AdminToken == "" { + return map[string]interface{}{"error": "admin_token not configured"}, nil + } + path := "/admin/records" + var parts []string + if t, ok := args["type"].(string); ok && t != "" { + parts = append(parts, "type="+t) + } + if n, ok := args["name"].(string); ok && n != "" { + parts = append(parts, "name="+n) + } + if len(parts) > 0 { + path += "?" + strings.Join(parts, "&") + } + headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} + result, _, err := doRequest(cfg, "GET", path, nil, headers) + return result, err +} + +func toolCreateRecord(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { + if cfg.AdminToken == "" { + return map[string]interface{}{"error": "admin_token not configured"}, nil + } + headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} + result, _, err := doRequest(cfg, "POST", "/admin/records", args, headers) + return result, err +} + +func toolUpdateRecord(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { + if cfg.AdminToken == "" { + return map[string]interface{}{"error": "admin_token not configured"}, nil + } + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} + result, _, err := doRequest(cfg, "PUT", "/admin/records/"+id, args, headers) + return result, err +} + +func toolDeleteRecord(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { + if cfg.AdminToken == "" { + return map[string]interface{}{"error": "admin_token not configured"}, nil + } + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} + _, status, err := doRequest(cfg, "DELETE", "/admin/records/"+id, nil, headers) + if err != nil { + return nil, err + } + if status == http.StatusNoContent { + return map[string]interface{}{"status": "deleted"}, nil + } + return map[string]interface{}{"status": "error", "code": status}, nil +} From 73dc97e2c9fb22256be70befc216b8ed56839fbb Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 21:33:35 +0200 Subject: [PATCH 4/8] feat: serve OpenAPI 3.1 spec at GET /openapi.json --- api.go | 10 ++++++++++ api_test.go | 12 ++++++++++++ main.go | 1 + 3 files changed, 23 insertions(+) diff --git a/api.go b/api.go index 9e192fbc..cb1664f4 100644 --- a/api.go +++ b/api.go @@ -3,6 +3,7 @@ package main import ( "crypto/subtle" "database/sql" + _ "embed" "encoding/json" "errors" "fmt" @@ -16,6 +17,9 @@ import ( "time" ) +//go:embed openapi.json +var openapiSpec []byte + // toFQDN ensures a DNS name ends with a trailing dot for consistent storage and lookup. func toFQDN(name string) string { name = strings.ToLower(name) @@ -135,6 +139,12 @@ func healthCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { w.WriteHeader(http.StatusOK) } +func serveOpenAPI(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(openapiSpec) +} + // adminRecordRequest is the request body for creating/updating a DNS record type adminRecordRequest struct { Name string `json:"name"` diff --git a/api_test.go b/api_test.go index fa8e91bb..b83cca55 100644 --- a/api_test.go +++ b/api_test.go @@ -72,6 +72,7 @@ func setupRouter(debug bool, noauth bool) http.Handler { }) api.POST("/register", webRegisterPost) api.GET("/health", healthCheck) + api.GET("/openapi.json", serveOpenAPI) if noauth { api.POST("/update", noAuth(webUpdatePost)) } else { @@ -443,6 +444,17 @@ func TestApiHealthCheck(t *testing.T) { e.GET("/health").Expect().Status(http.StatusOK) } +func TestOpenAPIEndpoint(t *testing.T) { + router := setupRouter(false, false) + server := httptest.NewServer(router) + defer server.Close() + e := getExpect(t, server) + resp := e.GET("/openapi.json").Expect() + resp.Status(http.StatusOK) + resp.Header("Content-Type").Contains("application/json") + resp.JSON().Object().ContainsKey("openapi") +} + func setupAdminRouter(t *testing.T, token string) (*httptest.Server, *httpexpect.Expect) { t.Helper() api := httprouter.New() diff --git a/main.go b/main.go index 119486e3..47ca2b91 100644 --- a/main.go +++ b/main.go @@ -131,6 +131,7 @@ func startHTTPAPI(errChan chan error, config DNSConfig, dnsServers []*DNSServer) } api.POST("/update", Auth(webUpdatePost)) api.GET("/health", healthCheck) + api.GET("/openapi.json", serveOpenAPI) if config.API.Admin.Token != "" { api.GET("/admin/records", adminBearerMiddleware(adminListRecords)) api.POST("/admin/records", adminBearerMiddleware(adminCreateRecord)) From cd7b6f28a72f02119d848974aed451b468dec0dd Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 21:59:16 +0200 Subject: [PATCH 5/8] feat: add MCP binary stdio entry point with JSON-RPC 2.0 protocol --- cmd/acme-dns-mcp/main.go | 77 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/cmd/acme-dns-mcp/main.go b/cmd/acme-dns-mcp/main.go index 38dd16da..4e36b697 100644 --- a/cmd/acme-dns-mcp/main.go +++ b/cmd/acme-dns-mcp/main.go @@ -1,3 +1,78 @@ package main -func main() {} +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result interface{} `json:"result,omitempty"` + Error interface{} `json:"error,omitempty"` +} + +func main() { + cfgPath := filepath.Join(os.Getenv("HOME"), ".acme-dns-mcp", "config.toml") + if v := os.Getenv("ACMEDNS_MCP_CONFIG"); v != "" { + cfgPath = v + } + cfg := loadConfig(cfgPath) + + scanner := bufio.NewScanner(os.Stdin) + encoder := json.NewEncoder(os.Stdout) + + for scanner.Scan() { + line := scanner.Bytes() + var req jsonRPCRequest + if err := json.Unmarshal(line, &req); err != nil { + continue + } + + var resp jsonRPCResponse + resp.JSONRPC = "2.0" + resp.ID = req.ID + + switch req.Method { + case "initialize": + resp.Result = map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{"tools": map[string]interface{}{}}, + "serverInfo": map[string]interface{}{"name": "acme-dns-mcp", "version": "1.0.0"}, + } + case "tools/list": + resp.Result = map[string]interface{}{"tools": listTools()} + case "tools/call": + toolName, _ := req.Params["name"].(string) + args, _ := req.Params["arguments"].(map[string]interface{}) + if args == nil { + args = map[string]interface{}{} + } + result, err := callTool(cfg, toolName, args) + if err != nil { + resp.Error = map[string]interface{}{"code": -32000, "message": err.Error()} + } else { + resultJSON, _ := json.Marshal(result) + resp.Result = map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": string(resultJSON)}, + }, + } + } + default: + resp.Error = map[string]interface{}{"code": -32601, "message": fmt.Sprintf("method not found: %s", req.Method)} + } + + _ = encoder.Encode(resp) + } +} From 4bb1d23adb8468608c32cfd1002d8ecee2009050 Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 22:00:15 +0200 Subject: [PATCH 6/8] chore: add acme-dns-mcp to goreleaser build pipeline --- .goreleaser.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index ce80d92d..fbc807e0 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -12,6 +12,14 @@ builds: goos: - linux - darwin + - id: acme-dns-mcp + main: ./cmd/acme-dns-mcp + binary: acme-dns-mcp + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin checksum: name_template: 'checksums.txt' snapshot: From c285111bf98dd85a816ac581f3f4ff0defca5801 Mon Sep 17 00:00:00 2001 From: I539231 Date: Sat, 30 May 2026 22:02:04 +0200 Subject: [PATCH 7/8] fix: return records array correctly from list_dns_records tool --- cmd/acme-dns-mcp/mcp_test.go | 28 ++++++++++++++++++++++++ cmd/acme-dns-mcp/tools.go | 42 ++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/cmd/acme-dns-mcp/mcp_test.go b/cmd/acme-dns-mcp/mcp_test.go index 13d1aa1b..a6423f64 100644 --- a/cmd/acme-dns-mcp/mcp_test.go +++ b/cmd/acme-dns-mcp/mcp_test.go @@ -81,3 +81,31 @@ func TestToolListTools(t *testing.T) { } } } + +func TestToolListRecords(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin/records" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"id":"test-id","name":"example.com","type":"A","value":"1.2.3.4","ttl":300,"created":0}]`)) + } + })) + defer srv.Close() + + cfg := mcpConfig{BaseURL: srv.URL, AdminToken: "test-token"} + result, err := callTool(cfg, "list_dns_records", map[string]interface{}{}) + if err != nil { + t.Fatalf("list_dns_records failed: %v", err) + } + records, ok := result["records"] + if !ok { + t.Fatalf("expected 'records' key in result, got %v", result) + } + arr, ok := records.([]interface{}) + if !ok { + t.Fatalf("expected records to be array, got %T", records) + } + if len(arr) != 1 { + t.Fatalf("expected 1 record, got %d", len(arr)) + } +} diff --git a/cmd/acme-dns-mcp/tools.go b/cmd/acme-dns-mcp/tools.go index 57495224..4040e8aa 100644 --- a/cmd/acme-dns-mcp/tools.go +++ b/cmd/acme-dns-mcp/tools.go @@ -77,7 +77,7 @@ func callTool(cfg mcpConfig, name string, args map[string]interface{}) (map[stri return nil, fmt.Errorf("unknown tool: %s", name) } -func doRequest(cfg mcpConfig, method, path string, body interface{}, headers map[string]string) (map[string]interface{}, int, error) { +func doRequest(cfg mcpConfig, method, path string, body interface{}, headers map[string]string) (interface{}, int, error) { var bodyReader io.Reader if body != nil { b, err := json.Marshal(body) @@ -101,7 +101,7 @@ func doRequest(cfg mcpConfig, method, path string, body interface{}, headers map return nil, 0, err } defer resp.Body.Close() - var result map[string]interface{} + var result interface{} _ = json.NewDecoder(resp.Body).Decode(&result) if result == nil { result = map[string]interface{}{} @@ -122,7 +122,13 @@ func toolHealthCheck(cfg mcpConfig) (map[string]interface{}, error) { func toolRegister(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { result, _, err := doRequest(cfg, "POST", "/register", args, nil) - return result, err + if err != nil { + return nil, err + } + if m, ok := result.(map[string]interface{}); ok { + return m, nil + } + return map[string]interface{}{"result": result}, nil } func toolUpdateTXT(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { @@ -134,7 +140,13 @@ func toolUpdateTXT(cfg mcpConfig, args map[string]interface{}) (map[string]inter "X-Api-Key": cfg.Password, } result, _, err := doRequest(cfg, "POST", "/update", args, headers) - return result, err + if err != nil { + return nil, err + } + if m, ok := result.(map[string]interface{}); ok { + return m, nil + } + return map[string]interface{}{"result": result}, nil } func toolListRecords(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { @@ -154,7 +166,11 @@ func toolListRecords(cfg mcpConfig, args map[string]interface{}) (map[string]int } headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} result, _, err := doRequest(cfg, "GET", path, nil, headers) - return result, err + if err != nil { + return nil, err + } + // GET /admin/records returns a JSON array — wrap it for uniform map return + return map[string]interface{}{"records": result}, nil } func toolCreateRecord(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { @@ -163,7 +179,13 @@ func toolCreateRecord(cfg mcpConfig, args map[string]interface{}) (map[string]in } headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} result, _, err := doRequest(cfg, "POST", "/admin/records", args, headers) - return result, err + if err != nil { + return nil, err + } + if m, ok := result.(map[string]interface{}); ok { + return m, nil + } + return map[string]interface{}{"result": result}, nil } func toolUpdateRecord(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { @@ -176,7 +198,13 @@ func toolUpdateRecord(cfg mcpConfig, args map[string]interface{}) (map[string]in } headers := map[string]string{"Authorization": "Bearer " + cfg.AdminToken} result, _, err := doRequest(cfg, "PUT", "/admin/records/"+id, args, headers) - return result, err + if err != nil { + return nil, err + } + if m, ok := result.(map[string]interface{}); ok { + return m, nil + } + return map[string]interface{}{"result": result}, nil } func toolDeleteRecord(cfg mcpConfig, args map[string]interface{}) (map[string]interface{}, error) { From da3ae59ef9c28efcf42ce86099a3a9ef428ded8b Mon Sep 17 00:00:00 2001 From: I539231 Date: Sun, 31 May 2026 21:51:12 +0200 Subject: [PATCH 8/8] fix: check error returns in mcp_test.go and tools.go to satisfy errcheck linter --- cmd/acme-dns-mcp/mcp_test.go | 41 ++++++++++++++++++++++++------------ cmd/acme-dns-mcp/tools.go | 2 +- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/cmd/acme-dns-mcp/mcp_test.go b/cmd/acme-dns-mcp/mcp_test.go index a6423f64..8346f0af 100644 --- a/cmd/acme-dns-mcp/mcp_test.go +++ b/cmd/acme-dns-mcp/mcp_test.go @@ -8,15 +8,21 @@ import ( ) func TestLoadConfigFromEnv(t *testing.T) { - os.Setenv("ACMEDNS_BASE_URL", "https://acmedns.example.com") - os.Setenv("ACMEDNS_ADMIN_TOKEN", "secret-admin") - os.Setenv("ACMEDNS_USERNAME", "user-uuid") - os.Setenv("ACMEDNS_PASSWORD", "user-pass") + require := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + require(os.Setenv("ACMEDNS_BASE_URL", "https://acmedns.example.com")) + require(os.Setenv("ACMEDNS_ADMIN_TOKEN", "secret-admin")) + require(os.Setenv("ACMEDNS_USERNAME", "user-uuid")) + require(os.Setenv("ACMEDNS_PASSWORD", "user-pass")) defer func() { - os.Unsetenv("ACMEDNS_BASE_URL") - os.Unsetenv("ACMEDNS_ADMIN_TOKEN") - os.Unsetenv("ACMEDNS_USERNAME") - os.Unsetenv("ACMEDNS_PASSWORD") + _ = os.Unsetenv("ACMEDNS_BASE_URL") + _ = os.Unsetenv("ACMEDNS_ADMIN_TOKEN") + _ = os.Unsetenv("ACMEDNS_USERNAME") + _ = os.Unsetenv("ACMEDNS_PASSWORD") }() cfg := loadConfig("") @@ -35,15 +41,22 @@ func TestLoadConfigFromEnv(t *testing.T) { } func TestLoadConfigFromFile(t *testing.T) { - f, _ := os.CreateTemp("", "mcp-cfg-*.toml") - defer os.Remove(f.Name()) - f.WriteString(` + f, err := os.CreateTemp("", "mcp-cfg-*.toml") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(f.Name()) }() + if _, err := f.WriteString(` base_url = "https://local.example.com" admin_token = "file-admin" username = "file-user" password = "file-pass" -`) - f.Close() +`); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } cfg := loadConfig(f.Name()) if cfg.BaseURL != "https://local.example.com" { @@ -87,7 +100,7 @@ func TestToolListRecords(t *testing.T) { if r.URL.Path == "/admin/records" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`[{"id":"test-id","name":"example.com","type":"A","value":"1.2.3.4","ttl":300,"created":0}]`)) + _, _ = w.Write([]byte(`[{"id":"test-id","name":"example.com","type":"A","value":"1.2.3.4","ttl":300,"created":0}]`)) } })) defer srv.Close() diff --git a/cmd/acme-dns-mcp/tools.go b/cmd/acme-dns-mcp/tools.go index 4040e8aa..ba4f07dc 100644 --- a/cmd/acme-dns-mcp/tools.go +++ b/cmd/acme-dns-mcp/tools.go @@ -100,7 +100,7 @@ func doRequest(cfg mcpConfig, method, path string, body interface{}, headers map if err != nil { return nil, 0, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result interface{} _ = json.NewDecoder(resp.Body).Decode(&result) if result == nil {