Skip to content
Open
8 changes: 8 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"crypto/subtle"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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)
Expand Down Expand Up @@ -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"`
Expand Down
12 changes: 12 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions cmd/acme-dns-mcp/config.go
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions cmd/acme-dns-mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package 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)
}
}
124 changes: 124 additions & 0 deletions cmd/acme-dns-mcp/mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main

import (
"net/http"
"net/http/httptest"
"os"
"testing"
)

func TestLoadConfigFromEnv(t *testing.T) {
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")
}()

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, 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"
`); 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" {
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)
}
}
}

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))
}
}
Loading
Loading