diff --git a/Taskfile.yml b/Taskfile.yml index fd3080c6..41f06de7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -17,6 +17,10 @@ tasks: desc: Run the wizard cmd: go run ./cmd/wizard {{.CLI_ARGS}} + validate: + desc: Validate a server + cmd: go run ./cmd/validate {{.CLI_ARGS}} + import: desc: Import a server into the registry cmd: docker mcp catalog import ./catalogs/{{.CLI_ARGS}}/catalog.yaml diff --git a/cmd/validate/main.go b/cmd/validate/main.go new file mode 100644 index 00000000..15877375 --- /dev/null +++ b/cmd/validate/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "flag" + "fmt" + "image" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + _ "image/jpeg" + _ "image/png" + + "github.com/docker/mcp-registry/internal/licenses" + "github.com/docker/mcp-registry/pkg/github" + "github.com/docker/mcp-registry/pkg/servers" + "gopkg.in/yaml.v3" +) + +func main() { + name := flag.String("name", "", "Name of the mcp server, name is guessed if not provided") + flag.Parse() + + if err := run(*name); err != nil { + log.Fatal(err) + } +} + +func run(name string) error { + if err := isNameValid(name); err != nil { + return err + } + + if err := isDirectoryValid(name); err != nil { + return err + } + + if err := areSecretsValid(name); err != nil { + return err + } + + if err := IsLicenseValid(name); err != nil { + return err + } + if err := isIconValid(name); err != nil { + return err + } + + return nil +} + +// check if the name is a valid +func isNameValid(name string) error { + // check if name has only letters, numbers, and hyphens + if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(name) { + return fmt.Errorf("name is not valid. It must be a lowercase string with only letters, numbers, and hyphens") + } + + fmt.Println("✅ Name is valid") + return nil +} + +// check if the directory is valid +// servers//server.yaml exists +func isDirectoryValid(name string) error { + _, err := os.Stat(filepath.Join("servers", name, "server.yaml")) + if err != nil { + return err + } + server, err := readServerYaml(name) + if err != nil { + return err + } + + // check if the server.yaml file has a valid name + if server.Name != name { + return fmt.Errorf("server.yaml file has a invalid name. It must be %s", name) + } + + fmt.Println("✅ Directory is valid") + return nil +} + +// check if the secrets are valid +// secrets must be prefixed with the name of the server +func areSecretsValid(name string) error { + // read the server.yaml file + server, err := readServerYaml(name) + if err != nil { + return err + } + + // check if the server.yaml file has a valid secrets + if len(server.Config.Secrets) > 0 { + for _, secret := range server.Config.Secrets { + if !strings.HasPrefix(secret.Name, name+".") { + return fmt.Errorf("secret %s is not valid. It must be prefixed with the name of the server", secret.Name) + } + } + } + + fmt.Println("✅ Secrets are valid") + return nil +} + +// check if the license is valid +// the license must be valid +func IsLicenseValid(name string) error { + ctx := context.Background() + client := github.New() + server, err := readServerYaml(name) + if err != nil { + return err + } + repository, err := client.GetProjectRepository(ctx, server.Source.Project) + if err != nil { + return err + } + + if !licenses.IsValid(repository.License) { + return fmt.Errorf("project %s is licensed under %s which may be incompatible with some tools", server.Source.Project, repository.License.GetName()) + } + fmt.Println("✅ License is valid") + + return nil +} + +func isIconValid(name string) error { + server, err := readServerYaml(name) + if err != nil { + return err + } + + if server.Image == "" { + return fmt.Errorf("image is not valid. It must be a valid image") + } + // fetch the image and check the size + resp, err := http.Get(server.About.Icon) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("image is not valid. It must be a valid image") + } + if resp.ContentLength > 2*1024*1024 { + return fmt.Errorf("image is too large. It must be less than 2MB") + } + img, format, err := image.DecodeConfig(resp.Body) + if err != nil { + return err + } + if format != "png" { + return fmt.Errorf("image is not a png. It must be a png") + } + + if img.Width > 512 || img.Height > 512 { + return fmt.Errorf("image is too large. It must be less than 512x512") + } + + fmt.Println("✅ Icon is valid") + return nil +} + +func readServerYaml(name string) (servers.Server, error) { + serverYaml, err := os.ReadFile(filepath.Join("servers", name, "server.yaml")) + if err != nil { + return servers.Server{}, err + } + var server servers.Server + err = yaml.Unmarshal(serverYaml, &server) + if err != nil { + return servers.Server{}, err + } + return server, nil +} diff --git a/cmd/validate/main_test.go b/cmd/validate/main_test.go new file mode 100644 index 00000000..a66369be --- /dev/null +++ b/cmd/validate/main_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "testing" +) + +func Test_isNameValid(t *testing.T) { + type args struct { + name string + } + tests := []struct { + name string + args args + wantError bool + }{ + { + name: "valid name", + args: args{ + name: "my-server", + }, + wantError: false, + }, + { + name: "invalid name", + args: args{ + name: "My-Server", + }, + wantError: true, + }, + { + name: "valid name with numbers", + args: args{ + name: "my-server-1", + }, + wantError: false, + }, + { + name: "invalid name with symbol", + args: args{ + name: "my-server-$", + }, + wantError: true, + }, + { + name: "invalid name with space", + args: args{ + name: "my server", + }, + wantError: true, + }, + { + name: "invalid name with slash", + args: args{ + name: "my-server/1", + }, + wantError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isNameValid(tt.args.name); (got != nil) != tt.wantError { + t.Errorf("isNameValid() = %v, want %v", got, tt.wantError) + } + }) + } +} + +func Test_areSecretsValid(t *testing.T) { + type args struct { + name string + } + tests := []struct { + name string + args args + wantError bool + }{ + { + name: "valid secrets", + args: args{ + name: "astra-db", + }, + wantError: false, + }, + { + name: "no secrets", + args: args{ + name: "arxiv-mcp-server", + }, + wantError: false, + }, + { + name: "invalid secrets", + args: args{ + name: "bad-server", + }, + wantError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := areSecretsValid(tt.args.name); (got != nil) != tt.wantError { + t.Errorf("areSecretsValid() = %v, want %v", got, tt.wantError) + } + }) + } +}