From 85ce6c76a412c9ca97c5f08d50b1b1cc6f29d639 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Mon, 16 Feb 2026 19:18:38 -0500 Subject: [PATCH 01/22] feat: mcp server for validate plugin --- go.mod | 3 +++ go.sum | 8 ++++++++ pkg/cmd/mcpserver/main.go | 41 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 pkg/cmd/mcpserver/main.go diff --git a/go.mod b/go.mod index 2d5c3646..de3a3278 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/hashicorp/go-version v1.8.0 github.com/jarcoal/httpmock v1.4.1 github.com/magefile/mage v1.15.0 + github.com/modelcontextprotocol/go-sdk v1.3.0 github.com/ossf/osv-schema/bindings/go v0.0.0-20251230224438-88c48750ddae github.com/r3labs/diff/v3 v3.0.2 github.com/smartystreets/goconvey v1.8.1 @@ -123,6 +124,7 @@ require ( github.com/google/generative-ai-go v0.15.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/osv-scalibr v0.4.1-0.20251202121049-5e7e15f4a036 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -194,6 +196,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.2 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index bf30440c..35c11975 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -262,6 +264,8 @@ github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/go-cpy v0.0.0-20211218193943-a9c933c06932 h1:5/4TSDzpDnHQ8rKEEQBjRlYx77mHOvXu08oGchxej7o= github.com/google/go-cpy v0.0.0-20211218193943-a9c933c06932/go.mod h1:cC6EdPbj/17GFCPDK39NRarlMI+kt+O60S12cNB5J9Y= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/osv-scalibr v0.4.1-0.20251202121049-5e7e15f4a036 h1:a+w+8ZQYYybXPWI1yJD+mXri5fMLcThlP41rIB7XNns= github.com/google/osv-scalibr v0.4.1-0.20251202121049-5e7e15f4a036/go.mod h1:9Ze2W6nQmu1WX2s95ezOAVZhPDbcA6ZGuEHgFT/sQEU= github.com/google/osv-scanner/v2 v2.3.1 h1:97NVCr8QNdS9deD8zxB0cIPI7vmcqAm8YJhclnXETu8= @@ -354,6 +358,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= +github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/montanaflynn/stats v0.6.3/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= @@ -510,6 +516,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go new file mode 100644 index 00000000..98b086a6 --- /dev/null +++ b/pkg/cmd/mcpserver/main.go @@ -0,0 +1,41 @@ +package mcpserver + +import ( + "context" + "fmt" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Input struct { + PluginPath string `json:"pluginPath" jsonschema:"required,description=The path to the plugin directory. This can be a local file path or a URL. If it's a URL, it must be a zip file."` + SourceCodeUri string `json:"sourceCodeUri" jsonschema:"description=The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` +} + +type Output struct { + Analysis string `json:"analysis" jsonschema:"description=The name of the analysis that was run."` + Report string `json:"report" jsonschema:"description=The report generated by comparing the source map with the source code."` +} + +func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { + return nil, Output{}, nil +} + +func run() error { + server := mcp.NewServer(&mcp.Implementation{Name: "plugin-validator", Version: "0.1.0"}, nil) + mcp.AddTool(server, &mcp.Tool{ + Name: "validate_plugin", + Description: "Validates a Grafana plugin against publishing requirements. Checks metadata, security, structure, and best practices. Returns detailed errors and warnings with actionable fix suggestions.", + }, ValidatePlugin) + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + return fmt.Errorf("failed to run server: %w", err) + } + return nil +} + +func main() { + if err := run(); err != nil { + log.Fatalf("failed to run: %v", err) + } +} From c5ccd777aebf5fcaeb25ed409762dd6f77abfe85 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 09:47:11 -0500 Subject: [PATCH 02/22] ref: extract plugin archive extraction and check runner to pkg --- pkg/cmd/plugincheck2/main.go | 182 +++-------------------------- pkg/service/validator.go | 214 +++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 169 deletions(-) create mode 100644 pkg/service/validator.go diff --git a/pkg/cmd/plugincheck2/main.go b/pkg/cmd/plugincheck2/main.go index 1ed8f1ea..da9ee79c 100644 --- a/pkg/cmd/plugincheck2/main.go +++ b/pkg/cmd/plugincheck2/main.go @@ -1,26 +1,17 @@ package main import ( - "bytes" - "crypto/md5" - "crypto/sha1" "flag" "fmt" "io" "os" - "path/filepath" - "strings" - "github.com/bmatcuk/doublestar/v4" "gopkg.in/yaml.v3" - "github.com/grafana/plugin-validator/pkg/analysis" "github.com/grafana/plugin-validator/pkg/analysis/output" - "github.com/grafana/plugin-validator/pkg/analysis/passes" - "github.com/grafana/plugin-validator/pkg/archivetool" "github.com/grafana/plugin-validator/pkg/logme" - "github.com/grafana/plugin-validator/pkg/repotool" "github.com/grafana/plugin-validator/pkg/runner" + "github.com/grafana/plugin-validator/pkg/service" ) func main() { @@ -93,106 +84,23 @@ func main() { pluginURL := flag.Args()[0] - // read archive file into bytes - b, err := archivetool.ReadArchive(pluginURL) + result, err := service.ValidatePlugin(service.Params{ + PluginURL: pluginURL, + SourceCodeUri: *sourceCodeUri, + Checksum: *checksum, + Analyzer: *analyzer, + AnalyzerSeverity: *analyzerSeverity, + Config: &cfg, + }) if err != nil { - logme.Errorln(fmt.Errorf("couldn't fetch plugin archive: %w", err)) + logme.Errorln(fmt.Errorf("couldn't validate plugin: %w", err)) os.Exit(1) } - - // write archive to a temp file - tmpZip, err := os.CreateTemp("", "plugin-archive") - if err != nil { - logme.Errorln(fmt.Errorf("couldn't create temporary file: %w", err)) - os.Exit(1) - } - defer os.Remove(tmpZip.Name()) - - if _, err := tmpZip.Write(b); err != nil { - logme.Errorln(fmt.Errorf("couldn't write temporary file: %w", err)) - os.Exit(1) - } - - logme.Debugln(fmt.Sprintf("Archive copied to tmp file: %s", tmpZip.Name())) - - md5hasher := md5.New() - md5hasher.Write(b) - md5hash := md5hasher.Sum(nil) - - sha1hasher := sha1.New() - sha1hasher.Write(b) - sha1hash := sha1hasher.Sum(nil) - - logme.Debugln(fmt.Sprintf("ArchiveCalculatedMD5: %x", md5hash)) - logme.Debugln(fmt.Sprintf("ArchiveCalculatedSHA1: %x", sha1hash)) - - // Extract the ZIP archive in a temporary directory. - archiveDir, archiveCleanup, err := archivetool.ExtractPlugin(bytes.NewReader(b)) - if err != nil { - logme.Errorln(fmt.Errorf("couldn't extract plugin archive: %w", err)) - os.Exit(1) - } - defer archiveCleanup() - - sourceCodeDir, sourceCodeDirCleanup, err := getSourceCodeDir(*sourceCodeUri) - if err != nil { - // if source code is not provided, we don't fail the validation - logme.Errorln(fmt.Errorf("couldn't get source code: %w", err)) - } - if sourceCodeDirCleanup != nil { - defer sourceCodeDirCleanup() - } - - analyzers := passes.Analyzers - severity := analysis.Severity("") - - if *analyzer != "" { - for _, a := range analyzers { - if a.Name == *analyzer { - analyzers = []*analysis.Analyzer{a} - - break - } - } - if *analyzerSeverity != "" { - severity = analysis.Severity(*analyzerSeverity) - } - } - - diags, err := runner.Check( - analyzers, - analysis.CheckParams{ - ArchiveFile: tmpZip.Name(), - ArchiveDir: archiveDir, - SourceCodeDir: sourceCodeDir, - SourceCodeReference: *sourceCodeUri, - Checksum: *checksum, - ArchiveCalculatedMD5: fmt.Sprintf("%x", md5hash), - ArchiveCalculatedSHA1: fmt.Sprintf("%x", sha1hash), - }, - cfg, - severity, - ) - if err != nil { - // we don't exit on error. we want to still report the diagnostics - logme.DebugFln("check failed: %v", err) - } - + diags := result.Diagnostics + pluginID := result.PluginID + pluginVersion := result.PluginVersion var outputMarshaler output.Marshaler - // Plugin ID and version (needed by JSON output) - pluginID, pluginVersion, err := GetIDAndVersion(archiveDir) - if err != nil { - pluginID, pluginVersion = GetIDAndVersionFallBack(archiveDir) - archiveDiag := analysis.Diagnostic{ - Name: "zip-invalid", - Severity: analysis.Error, - Title: "Plugin archive is improperly structured", - Detail: "It is possible your plugin archive structure is incorrect. Please see https://grafana.com/developers/plugin-tools/publish-a-plugin/package-a-plugin for more information on how to package a plugin.", - } - diags["archive"] = append(diags["archive"], archiveDiag) - } - // Additional JSON output to file if *outputToFile != "" { ob, err := output.NewJSONMarshaler(pluginID, pluginVersion).Marshal(diags) @@ -277,67 +185,3 @@ func readConfigFile(path string) (runner.Config, error) { return config, nil } - -func getSourceCodeDirSubDir(sourceCodePath string) string { - // check if there's a package.json in the source code directory - // if so return the source code directory as is - if _, err := os.Stat(filepath.Join(sourceCodePath, "package.json")); err == nil { - return sourceCodePath - } - - // use double start to find the first ocurrance of package.json - possiblePath, err := doublestar.FilepathGlob(sourceCodePath + "/**/package.json") - if err != nil { - return sourceCodePath - } - if len(possiblePath) == 0 { - return sourceCodePath - } - logme.DebugFln( - "Detected sourcecode inside a subdir: %v. Returning %s", - possiblePath, - filepath.Dir(possiblePath[0]), - ) - // possiblePath points to a file, return the dir - return filepath.Dir(possiblePath[0]) -} - -func getSourceCodeDir(sourceCodeUri string) (string, func(), error) { - // If source code URI is not provided, return immediately with an empty string - // otherwise we will get an error when trying to extract the source code archive - if sourceCodeUri == "" { - return "", func() {}, nil - } - - // file:// protocol for local directories - if strings.HasPrefix(sourceCodeUri, "file://") { - sourceCodeDir := strings.TrimPrefix(sourceCodeUri, "file://") - if _, err := os.Stat(sourceCodeDir); err != nil { - return "", nil, err - } - return sourceCodeDir, func() {}, nil - } - - if repotool.IsSupportedGitUrl(sourceCodeUri) { - extractedGitRepo, sourceCodeCleanUp, err := repotool.GitUrlToLocalPath(sourceCodeUri) - if err != nil { - return "", sourceCodeCleanUp, err - } - return extractedGitRepo, sourceCodeCleanUp, nil - } - - // assume is an archive url - extractedDir, sourceCodeCleanUp, err := archivetool.ArchiveToLocalPath(sourceCodeUri) - if err != nil { - return "", sourceCodeCleanUp, fmt.Errorf( - "couldn't extract source code archive: %s. %w", - sourceCodeUri, - err, - ) - } - // some submissions from zip have their source code in a subdirectory - // of the extracted archive - extractedDir = getSourceCodeDirSubDir(extractedDir) - return extractedDir, sourceCodeCleanUp, nil - -} diff --git a/pkg/service/validator.go b/pkg/service/validator.go new file mode 100644 index 00000000..0fc95b99 --- /dev/null +++ b/pkg/service/validator.go @@ -0,0 +1,214 @@ +package service + +import ( + "bytes" + "crypto/md5" + "crypto/sha1" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes" + "github.com/grafana/plugin-validator/pkg/archivetool" + "github.com/grafana/plugin-validator/pkg/logme" + "github.com/grafana/plugin-validator/pkg/repotool" + "github.com/grafana/plugin-validator/pkg/runner" + "github.com/grafana/plugin-validator/pkg/utils" +) + +type Params struct { + PluginURL string + SourceCodeUri string + Checksum string + Analyzer string + AnalyzerSeverity string + Config *runner.Config +} + +type Result struct { + Diagnostics analysis.Diagnostics + PluginID string + PluginVersion string +} + +func ValidatePlugin(params Params) (Result, error) { + // read archive file into bytes + b, err := archivetool.ReadArchive(params.PluginURL) + if err != nil { + err = fmt.Errorf("couldn't read plugin archive: %w", err) + logme.Errorln(err) + return Result{}, err + } + + // write archive to a temp file + tmpZip, err := os.CreateTemp("", "plugin-archive") + if err != nil { + err = fmt.Errorf("couldn't create temporary file: %w", err) + logme.Errorln(err) + return Result{}, err + } + defer os.Remove(tmpZip.Name()) + + if _, err := tmpZip.Write(b); err != nil { + err = fmt.Errorf("couldn't write temporary file: %w", err) + logme.Errorln(err) + return Result{}, err + } + + logme.Debugln(fmt.Sprintf("Archive copied to tmp file: %s", tmpZip.Name())) + + md5hasher := md5.New() + md5hasher.Write(b) + md5hash := md5hasher.Sum(nil) + + sha1hasher := sha1.New() + sha1hasher.Write(b) + sha1hash := sha1hasher.Sum(nil) + + logme.Debugln(fmt.Sprintf("ArchiveCalculatedMD5: %x", md5hash)) + logme.Debugln(fmt.Sprintf("ArchiveCalculatedSHA1: %x", sha1hash)) + + // Extract the ZIP archive in a temporary directory. + archiveDir, archiveCleanup, err := archivetool.ExtractPlugin(bytes.NewReader(b)) + if err != nil { + err = fmt.Errorf("couldn't extract plugin archive: %w", err) + logme.Errorln(err) + return Result{}, err + } + defer archiveCleanup() + + sourceCodeDir, sourceCodeDirCleanup, err := getSourceCodeDir(params.SourceCodeUri) + if err != nil { + // if source code is not provided, we don't fail the validation + logme.Errorln(fmt.Errorf("couldn't get source code: %w", err)) + } + if sourceCodeDirCleanup != nil { + defer sourceCodeDirCleanup() + } + + analyzers := passes.Analyzers + severity := analysis.Severity("") + + if params.Analyzer != "" { + for _, a := range analyzers { + if a.Name == params.Analyzer { + analyzers = []*analysis.Analyzer{a} + + break + } + } + if params.AnalyzerSeverity != "" { + severity = analysis.Severity(params.AnalyzerSeverity) + } + } + + if params.Config == nil { + params.Config = &runner.Config{ + Global: runner.GlobalConfig{ + Enabled: true, + }, + } + } + + diags, err := runner.Check( + analyzers, + analysis.CheckParams{ + ArchiveFile: tmpZip.Name(), + ArchiveDir: archiveDir, + SourceCodeDir: sourceCodeDir, + SourceCodeReference: params.SourceCodeUri, + Checksum: params.Checksum, + ArchiveCalculatedMD5: fmt.Sprintf("%x", md5hash), + ArchiveCalculatedSHA1: fmt.Sprintf("%x", sha1hash), + }, + *params.Config, + severity, + ) + if err != nil { + // we don't exit on error. we want to still report the diagnostics + logme.DebugFln("check failed: %v", err) + } + + metadata, err := utils.GetPluginMetadata(archiveDir) + if err != nil { + archiveDiag := analysis.Diagnostic{ + Name: "zip-invalid", + Severity: analysis.Error, + Title: "Plugin archive is improperly structured", + Detail: "It is possible your plugin archive structure is incorrect. Please see https://grafana.com/developers/plugin-tools/publish-a-plugin/package-a-plugin for more information on how to package a plugin.", + } + diags["archive"] = append(diags["archive"], archiveDiag) + } + + return Result{ + Diagnostics: diags, + PluginID: metadata.ID, + PluginVersion: metadata.Info.Version, + }, nil +} + +func getSourceCodeDirSubDir(sourceCodePath string) string { + // check if there's a package.json in the source code directory + // if so return the source code directory as is + if _, err := os.Stat(filepath.Join(sourceCodePath, "package.json")); err == nil { + return sourceCodePath + } + + // use double start to find the first ocurrance of package.json + possiblePath, err := doublestar.FilepathGlob(sourceCodePath + "/**/package.json") + if err != nil { + return sourceCodePath + } + if len(possiblePath) == 0 { + return sourceCodePath + } + logme.DebugFln( + "Detected sourcecode inside a subdir: %v. Returning %s", + possiblePath, + filepath.Dir(possiblePath[0]), + ) + // possiblePath points to a file, return the dir + return filepath.Dir(possiblePath[0]) +} + +func getSourceCodeDir(sourceCodeUri string) (string, func(), error) { + // If source code URI is not provided, return immediately with an empty string + // otherwise we will get an error when trying to extract the source code archive + if sourceCodeUri == "" { + return "", func() {}, nil + } + + // file:// protocol for local directories + if strings.HasPrefix(sourceCodeUri, "file://") { + sourceCodeDir := strings.TrimPrefix(sourceCodeUri, "file://") + if _, err := os.Stat(sourceCodeDir); err != nil { + return "", nil, err + } + return sourceCodeDir, func() {}, nil + } + + if repotool.IsSupportedGitUrl(sourceCodeUri) { + extractedGitRepo, sourceCodeCleanUp, err := repotool.GitUrlToLocalPath(sourceCodeUri) + if err != nil { + return "", sourceCodeCleanUp, err + } + return extractedGitRepo, sourceCodeCleanUp, nil + } + + // assume is an archive url + extractedDir, sourceCodeCleanUp, err := archivetool.ArchiveToLocalPath(sourceCodeUri) + if err != nil { + return "", sourceCodeCleanUp, fmt.Errorf( + "couldn't extract source code archive: %s. %w", + sourceCodeUri, + err, + ) + } + // some submissions from zip have their source code in a subdirectory + // of the extracted archive + extractedDir = getSourceCodeDirSubDir(extractedDir) + return extractedDir, sourceCodeCleanUp, nil +} From 1883e3ebb021cb43f5909b0c5c34d5c5bdd3662d Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 09:47:53 -0500 Subject: [PATCH 03/22] feat: run validate_plugin from mcp --- pkg/analysis/analysis.go | 22 +++++++++++----------- pkg/cmd/mcpserver/main.go | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/pkg/analysis/analysis.go b/pkg/analysis/analysis.go index 09419a74..27f41d2c 100644 --- a/pkg/analysis/analysis.go +++ b/pkg/analysis/analysis.go @@ -17,12 +17,12 @@ var ( ) type Pass struct { - AnalyzerName string - RootDir string - CheckParams CheckParams - ResultOf map[*Analyzer]any - Report func(string, Diagnostic) - Diagnostics *Diagnostics + AnalyzerName string + RootDir string + CheckParams CheckParams + ResultOf map[*Analyzer]any + Report func(string, Diagnostic) + Diagnostics *Diagnostics } type CheckParams struct { @@ -85,11 +85,11 @@ func (p *Pass) AnalyzerHasErrors(a *Analyzer) bool { } type Diagnostic struct { - Severity Severity - Title string - Detail string - Context string `json:"Context,omitempty"` - Name string + Severity Severity `json:"Severity" jsonschema:"description=The severity level of the issue."` + Title string `json:"Title" jsonschema:"description=A short, human-readable summary of the issue."` + Detail string `json:"Detail" jsonschema:"description=A detailed description of the issue."` + Context string `json:"Context,omitempty" jsonschema:"description=Additional context about the issue."` + Name string `json:"Name" jsonschema:"description=The name of the analysis that was run."` } type Diagnostics map[string][]Diagnostic diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index 98b086a6..f17847eb 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -5,6 +5,8 @@ import ( "fmt" "log" + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/service" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -14,12 +16,20 @@ type Input struct { } type Output struct { - Analysis string `json:"analysis" jsonschema:"description=The name of the analysis that was run."` - Report string `json:"report" jsonschema:"description=The report generated by comparing the source map with the source code."` + Diagnostics analysis.Diagnostics `json:"diagnostics" jsonschema:"description=The diagnostics results of the plugin validation. This includes errors, warnings, and recommendations for improving the plugin."` } func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { - return nil, Output{}, nil + res, err := service.ValidatePlugin( + service.Params{ + PluginURL: input.PluginPath, + SourceCodeUri: input.SourceCodeUri, + }, + ) + if err != nil { + return nil, Output{}, fmt.Errorf("couldn't validate plugin: %w", err) + } + return nil, Output{Diagnostics: res.Diagnostics}, nil } func run() error { From e2d30cf3fdac09b6d3bced5a4c46654c08623ea8 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 10:04:46 -0500 Subject: [PATCH 04/22] test: add test for mcpserver --- pkg/cmd/mcpserver/main.go | 15 +++++++-- pkg/cmd/mcpserver/main_test.go | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/mcpserver/main_test.go diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index f17847eb..72d46b1b 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -1,4 +1,4 @@ -package mcpserver +package main import ( "context" @@ -27,7 +27,18 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) }, ) if err != nil { - return nil, Output{}, fmt.Errorf("couldn't validate plugin: %w", err) + // Need to return diagnostics even in case of error, to provide feedback on what went wrong + diagnostics := analysis.Diagnostics{ + "validation": []analysis.Diagnostic{ + { + Name: "validation-error", + Severity: analysis.Error, + Title: "Plugin validation failed", + Detail: err.Error(), + }, + }, + } + return nil, Output{Diagnostics: diagnostics}, nil } return nil, Output{Diagnostics: res.Diagnostics}, nil } diff --git a/pkg/cmd/mcpserver/main_test.go b/pkg/cmd/mcpserver/main_test.go new file mode 100644 index 00000000..64beec6a --- /dev/null +++ b/pkg/cmd/mcpserver/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestValidatePlugin_InvalidZip(t *testing.T) { + archivePath := filepath.Join("..", "plugincheck2", "testdata", "invalid.zip") + input := Input{ + PluginPath: archivePath, + SourceCodeUri: "", + } + req := &mcp.CallToolRequest{} + + _, output, err := ValidatePlugin(context.Background(), req, input) + if err != nil { + t.Logf("Got error (this might be expected): %v", err) + } + + // Check that diagnostics contain error-level issues + hasError := false + for _, diags := range output.Diagnostics { + for _, d := range diags { + if d.Severity == "error" { + hasError = true + t.Logf("Found error diagnostic: %s - %s", d.Title, d.Detail) + } + } + } + + if !hasError { + t.Error("Expected error-level diagnostics for invalid zip, got none") + } +} + +func TestValidatePlugin_ValidZip(t *testing.T) { + archivePath := filepath.Join("..", "plugincheck2", "testdata", "alexanderzobnin-zabbix-app-4.4.9.linux_amd64.zip") + input := Input{ + PluginPath: archivePath, + SourceCodeUri: "", + } + req := &mcp.CallToolRequest{} + + _, output, err := ValidatePlugin(context.Background(), req, input) + if err != nil { + t.Fatalf("ValidatePlugin returned error: %v", err) + } + + if len(output.Diagnostics) == 0 { + t.Errorf("Expected diagnostics, got none") + } + t.Logf("Got %d diagnostic groups", len(output.Diagnostics)) +} From 05cccebae05b28841e065f5849ad6d922d3cc4cf Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 10:47:34 -0500 Subject: [PATCH 05/22] feat: check project structure --- pkg/service/validator.go | 22 ++++++++++++++++++---- pkg/utils/utils.go | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/pkg/service/validator.go b/pkg/service/validator.go index 0fc95b99..24ac9381 100644 --- a/pkg/service/validator.go +++ b/pkg/service/validator.go @@ -132,8 +132,22 @@ func ValidatePlugin(params Params) (Result, error) { logme.DebugFln("check failed: %v", err) } - metadata, err := utils.GetPluginMetadata(archiveDir) - if err != nil { + // Check if archive has proper structure (like old GetIDAndVersion did) + hasProperStructure := utils.HasProperArchiveStructure(archiveDir) + + // Try to get metadata using glob pattern + // finds the plugin.json file anywhere in the archive + metadata, metadataErr := utils.GetPluginMetadata(archiveDir) + + pluginID := "unknown" + pluginVersion := "unknown" + if metadataErr == nil { + pluginID = metadata.ID + pluginVersion = metadata.Info.Version + } + + // Add diagnostic if structure is improper (matching old behavior when GetIDAndVersion failed) + if !hasProperStructure { archiveDiag := analysis.Diagnostic{ Name: "zip-invalid", Severity: analysis.Error, @@ -145,8 +159,8 @@ func ValidatePlugin(params Params) (Result, error) { return Result{ Diagnostics: diags, - PluginID: metadata.ID, - PluginVersion: metadata.Info.Version, + PluginID: pluginID, + PluginVersion: pluginVersion, }, nil } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 851015f3..3d436156 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "github.com/bmatcuk/doublestar/v4" "github.com/grafana/plugin-validator/pkg/analysis/passes/metadata" @@ -65,3 +66,25 @@ func GetPluginMetadata(archiveDir string) (*metadata.Metadata, error) { } return &pluginJson, nil } + +// HasProperArchiveStructure checks if the archive has the proper structure: +// single top-level directory containing plugin.json +func HasProperArchiveStructure(archiveDir string) bool { + fis, err := os.ReadDir(archiveDir) + if err != nil || len(fis) == 0 { + return false + } + + // Check if first entry is a directory + if !fis[0].IsDir() { + return false + } + + // Check if plugin.json exists in that directory + pluginJsonPath := filepath.Join(archiveDir, fis[0].Name(), "plugin.json") + if _, err := os.Stat(pluginJsonPath); err != nil { + return false + } + + return true +} From 4d104ef7b7789b4ccbcb0b25f82e3c992cc0c341 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 10:56:13 -0500 Subject: [PATCH 06/22] chore: readme.md --- README.md | 121 ++++++++++++++----------- pkg/cmd/mcpserver/README.md | 172 ++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 53 deletions(-) create mode 100644 pkg/cmd/mcpserver/README.md diff --git a/README.md b/README.md index babf03b0..2a09047c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ Then you can run the utility: plugincheck2 -sourceCodeUri [source_code_location/] [plugin_archive.zip] ``` +### MCP Server (for AI assistants) + +The plugin validator can also be used as an MCP (Model Context Protocol) server, which allows AI assistants and code editors like Claude, VS Code with Continue, and Cline to validate Grafana plugins directly. + +To build and use the MCP server: + +```SHELL +git clone git@github.com:grafana/plugin-validator.git +cd plugin-validator +go build -o ~/.local/bin/plugin-validator-mcp ./pkg/cmd/mcpserver +``` + +For detailed configuration instructions for different AI tools and editors, see the [MCP Server README](pkg/cmd/mcpserver/README.md). + ### Generating local files For validation You must create a `.zip` archive containing the `dist/` directory but named as your plugin ID: @@ -186,7 +200,6 @@ analyzers: - my-plugin-id ``` - ### Source code You can specify the location of the plugin source code to the validator with the `-sourceCodeUri` option. Doing so allows for additional [analyzers](#analyzers) to be run and for a more complete scan. @@ -248,58 +261,60 @@ The tool runs a series of analyzers to ensure submitted plugins are following be THE FOLLOWING SECTION IS GENERATED, DO NOT EDIT. Run "mage gen:readme" to regenerate this section. --> -| Analyzer | Description | Dependencies | -|----------|-------------|--------------| -| Archive Name / `archivename` | The name of the archive should be correctly formatted. | None | -| Archive Structure / `archive` | Ensures the contents of the zip file have the expected layout. | None | -| Backend Binary / `backendbinary` | Validates the consistency between the existence of a binary file and plugin.json declarations for backend or alerting. | None | -| Backend Debug / `backenddebug` | Checks that the standalone debug files for backend plugins are not present. | None | -| Binary Permissions / `binarypermissions` | For datasources and apps with binaries, this ensures the plugin can run when extracted on a system. | None | -| Broken Links / `brokenlinks` | Detects if any URL doesn't resolve to a valid location. | None | -| Build Tools / `buildtools` | Checks that the plugin uses Grafana's standard create-plugin build tooling. | None | -| Changelog (exists) / `changelog` | Ensures a `CHANGELOG.md` file exists within the zip file. | None | -| Checksum / `checksum` | Validates that the passed checksum (as a validator arg) is the one calculated from the archive file. | `checksum` | -| Circular Dependencies / `circulardependencies` | Ensures that there aren't any circular dependencies between plugins (`plugin.json`, `dependencies.plugins` field). | None | -| Code Diff / `codediff` | | Google API Key with Generative AI access | -| Code Rules / `code-rules` | Checks for forbidden access to environment variables, file system or use of syscall module. | [semgrep](https://github.com/returntocorp/semgrep), `sourceCodeUri` | -| Developer Jargon / `jargon` | Generally discourages use of code jargon in the documentation. | None | -| Discoverability / `discoverability` | Warns about missing keywords and description that are used for plugin indexing in the catalog. | None | -| Go Manifest / `go-manifest` | Validates the build manifest. | None | -| Go Security Checker / `go-sec` | Inspects source code for security problems by scanning the Go AST. | [gosec](https://github.com/securego/gosec), `sourceCodeUri` | -| JS Source Map / `jsMap` | Checks for required `module.js.map` file(s) in archive. | `sourceCodeUri` | -| Legacy Grafana Toolkit usage / `legacybuilder` | Detects the usage of the not longer supported Grafana Toolkit. | None | -| Legacy Platform / `legacyplatform` | Detects use of Angular which is deprecated. | None | -| License Type / `license` | Checks the declared license is one of: BSD, MIT, Apache 2.0, LGPL3, GPL3, AGPL3. | None | -| LLM Review / `llmreview` | Runs the code through Gemini LLM to check for security issues or disallowed usage. | Gemini API key | -| Logos / `logos` | Detects whether the plugin includes small and large logos to display in the plugin catalog. | None | -| Manifest (Signing) / `manifest` | When a plugin is signed, the zip file will contain a signed `MANIFEST.txt` file. | None | -| Metadata / `metadata` | Checks that `plugin.json` exists and is valid. | None | -| Metadata Grafana Dependency / `grafanadependency` | Checks that dependencies.grafanaDependency in `plugin.json` is valid. | None | -| Metadata Paths / `metadatapaths` | Ensures all paths are valid and images referenced exist. | None | -| Metadata Validity / `metadatavalid` | Ensures metadata is valid and matches plugin schema. | None | -| module.js (exists) / `modulejs` | All plugins require a `module.js` to be loaded. | None | -| Nested includes metadata / `includesnested` | Validates that nested plugins have the correct metadata. | None | -| Nested Metadata / `nestedmetadata` | Recursively checks that all `plugin.json` exist and are valid. | None | -| No Tracking Scripts / `trackingscripts` | Detects if there are any known tracking scripts, which are not allowed. | None | -| Organization (exists) / `org` | Verifies the org specified in the plugin ID exists. | None | -| package.json / `packagejson` | Ensures that package.json exists and the version matches the plugin.json | None | -| Plugin Name formatting / `pluginname` | Validates the plugin ID used conforms to our naming convention. | None | -| Provenance attestation validation / `provenance` | Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions). | None | -| Published / `published-plugin` | Detects whether any version of this plugin exists in the Grafana plugin catalog currently. | None | -| Readme (exists) / `readme` | Ensures a `README.md` file exists within the zip file. | None | -| Restrictive Dependency / `restrictivedep` | Specifies a valid range of Grafana versions that work with this version of the plugin. | None | -| Safe Links / `safelinks` | Checks that links from `plugin.json` are safe. | None | -| Screenshots / `screenshots` | Screenshots are specified in `plugin.json` that will be used in the Grafana plugin catalog. | None | -| SDK Usage / `sdkusage` | Ensures that `grafana-plugin-sdk-go` is up-to-date. | None | -| Signature / `signature` | Ensures the plugin has a valid signature. | None | -| Source Code / `sourcecode` | A comparison is made between the zip file and the source code to ensure what is released matches the repo associated with it. | `sourceCodeUri` | -| Sponsorship Link / `sponsorshiplink` | Checks if a sponsorship link is specified in `plugin.json` that will be shown in the Grafana plugin catalog for users to support the plugin developer. | None | -| Type Suffix (panel/app/datasource) / `typesuffix` | Ensures the plugin has a valid type specified. | None | -| Unique README.md / `templatereadme` | Ensures the plugin doesn't re-use the template from the `create-plugin` tool. | None | -| Unsafe SVG / `unsafesvg` | Checks if any svg files are safe based on a whitelist of elements and attributes. | None | -| Version / `version` | Ensures the version submitted is newer than the currently published plugin. If this is a new/unpublished plugin, this is skipped. | None | -| Virus Scan / `virusscan` | Runs a virus scan on the plugin archive and source code using `clamscan` (`clamav`). | clamscan | -| Vulnerability Scanner / `osv-scanner` | Detects critical vulnerabilities in Go modules and yarn lock files. | [osv-scanner](https://github.com/google/osv-scanner), `sourceCodeUri` | + +| Analyzer | Description | Dependencies | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| Archive Name / `archivename` | The name of the archive should be correctly formatted. | None | +| Archive Structure / `archive` | Ensures the contents of the zip file have the expected layout. | None | +| Backend Binary / `backendbinary` | Validates the consistency between the existence of a binary file and plugin.json declarations for backend or alerting. | None | +| Backend Debug / `backenddebug` | Checks that the standalone debug files for backend plugins are not present. | None | +| Binary Permissions / `binarypermissions` | For datasources and apps with binaries, this ensures the plugin can run when extracted on a system. | None | +| Broken Links / `brokenlinks` | Detects if any URL doesn't resolve to a valid location. | None | +| Build Tools / `buildtools` | Checks that the plugin uses Grafana's standard create-plugin build tooling. | None | +| Changelog (exists) / `changelog` | Ensures a `CHANGELOG.md` file exists within the zip file. | None | +| Checksum / `checksum` | Validates that the passed checksum (as a validator arg) is the one calculated from the archive file. | `checksum` | +| Circular Dependencies / `circulardependencies` | Ensures that there aren't any circular dependencies between plugins (`plugin.json`, `dependencies.plugins` field). | None | +| Code Diff / `codediff` | | Google API Key with Generative AI access | +| Code Rules / `code-rules` | Checks for forbidden access to environment variables, file system or use of syscall module. | [semgrep](https://github.com/returntocorp/semgrep), `sourceCodeUri` | +| Developer Jargon / `jargon` | Generally discourages use of code jargon in the documentation. | None | +| Discoverability / `discoverability` | Warns about missing keywords and description that are used for plugin indexing in the catalog. | None | +| Go Manifest / `go-manifest` | Validates the build manifest. | None | +| Go Security Checker / `go-sec` | Inspects source code for security problems by scanning the Go AST. | [gosec](https://github.com/securego/gosec), `sourceCodeUri` | +| JS Source Map / `jsMap` | Checks for required `module.js.map` file(s) in archive. | `sourceCodeUri` | +| Legacy Grafana Toolkit usage / `legacybuilder` | Detects the usage of the not longer supported Grafana Toolkit. | None | +| Legacy Platform / `legacyplatform` | Detects use of Angular which is deprecated. | None | +| License Type / `license` | Checks the declared license is one of: BSD, MIT, Apache 2.0, LGPL3, GPL3, AGPL3. | None | +| LLM Review / `llmreview` | Runs the code through Gemini LLM to check for security issues or disallowed usage. | Gemini API key | +| Logos / `logos` | Detects whether the plugin includes small and large logos to display in the plugin catalog. | None | +| Manifest (Signing) / `manifest` | When a plugin is signed, the zip file will contain a signed `MANIFEST.txt` file. | None | +| Metadata / `metadata` | Checks that `plugin.json` exists and is valid. | None | +| Metadata Grafana Dependency / `grafanadependency` | Checks that dependencies.grafanaDependency in `plugin.json` is valid. | None | +| Metadata Paths / `metadatapaths` | Ensures all paths are valid and images referenced exist. | None | +| Metadata Validity / `metadatavalid` | Ensures metadata is valid and matches plugin schema. | None | +| module.js (exists) / `modulejs` | All plugins require a `module.js` to be loaded. | None | +| Nested includes metadata / `includesnested` | Validates that nested plugins have the correct metadata. | None | +| Nested Metadata / `nestedmetadata` | Recursively checks that all `plugin.json` exist and are valid. | None | +| No Tracking Scripts / `trackingscripts` | Detects if there are any known tracking scripts, which are not allowed. | None | +| Organization (exists) / `org` | Verifies the org specified in the plugin ID exists. | None | +| package.json / `packagejson` | Ensures that package.json exists and the version matches the plugin.json | None | +| Plugin Name formatting / `pluginname` | Validates the plugin ID used conforms to our naming convention. | None | +| Provenance attestation validation / `provenance` | Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions). | None | +| Published / `published-plugin` | Detects whether any version of this plugin exists in the Grafana plugin catalog currently. | None | +| Readme (exists) / `readme` | Ensures a `README.md` file exists within the zip file. | None | +| Restrictive Dependency / `restrictivedep` | Specifies a valid range of Grafana versions that work with this version of the plugin. | None | +| Safe Links / `safelinks` | Checks that links from `plugin.json` are safe. | None | +| Screenshots / `screenshots` | Screenshots are specified in `plugin.json` that will be used in the Grafana plugin catalog. | None | +| SDK Usage / `sdkusage` | Ensures that `grafana-plugin-sdk-go` is up-to-date. | None | +| Signature / `signature` | Ensures the plugin has a valid signature. | None | +| Source Code / `sourcecode` | A comparison is made between the zip file and the source code to ensure what is released matches the repo associated with it. | `sourceCodeUri` | +| Sponsorship Link / `sponsorshiplink` | Checks if a sponsorship link is specified in `plugin.json` that will be shown in the Grafana plugin catalog for users to support the plugin developer. | None | +| Type Suffix (panel/app/datasource) / `typesuffix` | Ensures the plugin has a valid type specified. | None | +| Unique README.md / `templatereadme` | Ensures the plugin doesn't re-use the template from the `create-plugin` tool. | None | +| Unsafe SVG / `unsafesvg` | Checks if any svg files are safe based on a whitelist of elements and attributes. | None | +| Version / `version` | Ensures the version submitted is newer than the currently published plugin. If this is a new/unpublished plugin, this is skipped. | None | +| Virus Scan / `virusscan` | Runs a virus scan on the plugin archive and source code using `clamscan` (`clamav`). | clamscan | +| Vulnerability Scanner / `osv-scanner` | Detects critical vulnerabilities in Go modules and yarn lock files. | [osv-scanner](https://github.com/google/osv-scanner), `sourceCodeUri` | + ## Output diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md new file mode 100644 index 00000000..ba18a252 --- /dev/null +++ b/pkg/cmd/mcpserver/README.md @@ -0,0 +1,172 @@ +# Plugin Validator MCP Server + +An MCP (Model Context Protocol) server that provides Grafana plugin validation capabilities to AI assistants and code editors. + +## Building + +### Using Go + +```bash +# From the project root +go build -o bin/mcpserver ./pkg/cmd/mcpserver + +# Or using mage +mage build:commands +``` + +### Using Docker + +Build the binary using Docker (no Go installation required): + +```bash +# Build the Docker image (builds all binaries including mcpserver) +docker build -t plugin-validator-build . + +# Extract the mcpserver binary from the image +docker create --name temp-container plugin-validator-build +docker cp temp-container:/app/bin/linux_amd64/mcpserver ./mcpserver +docker rm temp-container + +# Move to installation directory +mkdir -p ~/.local/bin +mv ./mcpserver ~/.local/bin/plugin-validator-mcp +chmod +x ~/.local/bin/plugin-validator-mcp +``` + +## Installation + +### Quick Install (Linux/macOS) + +```bash +# Build and install to local bin +go build -o ~/.local/bin/plugin-validator-mcp ./pkg/cmd/mcpserver + +# Make sure ~/.local/bin is in your PATH +export PATH="$HOME/.local/bin:$PATH" +``` + +## Configuration + +### Claude Desktop (macOS) + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp" + } + } +} +``` + +### Claude Desktop (Linux) + +Edit `~/.config/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" + } + } +} +``` + +### VS Code with Continue Extension + +Edit `~/.continue/config.json` (Linux/macOS): + +```json +{ + "mcpServers": [ + { + "name": "plugin-validator", + "command": "~/.local/bin/plugin-validator-mcp" + } + ] +} +``` + +### Cline (VS Code Extension) + +Edit `~/.cline/mcp_settings.json` (Linux/macOS): + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", + "args": [] + } + } +} +``` + +## Usage + +Once configured, you can ask your AI assistant to validate Grafana plugins: + +``` +Validate this Grafana plugin: /path/to/plugin.zip +``` + +``` +Check this plugin with source code: +- Plugin: ./my-plugin.zip +- Source: https://github.com/user/my-plugin +``` + +## Tool Details + +### validate_plugin + +Validates a Grafana plugin against publishing requirements. + +**Inputs:** + +- `pluginPath` (required): Path or URL to the plugin archive (.zip) +- `sourceCodeUri` (optional): Path or URL to plugin source code (zip, folder, or git repo) + +**Output:** + +- `diagnostics`: Structured validation results with errors, warnings, and recommendations + +## Troubleshooting + +### Server not found + +Make sure the binary path is correct: + +```bash +which plugin-validator-mcp +# or +ls -la ~/.local/bin/plugin-validator-mcp +``` + +### Permission denied + +Make the binary executable: + +```bash +chmod +x ~/.local/bin/plugin-validator-mcp +``` + +### Test manually + +Run the server directly to check for errors: + +```bash +~/.local/bin/plugin-validator-mcp +# Press Ctrl+C to exit +``` + +## Development + +Run tests: + +```bash +go test ./pkg/cmd/mcpserver -v +``` From 7192e2d5ad23087ac657a3aed5691af86a5a9cfd Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 11:27:08 -0500 Subject: [PATCH 07/22] chore: regenerated readme --- pkg/cmd/mcpserver/README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md index ba18a252..ee60b122 100644 --- a/pkg/cmd/mcpserver/README.md +++ b/pkg/cmd/mcpserver/README.md @@ -4,8 +4,6 @@ An MCP (Model Context Protocol) server that provides Grafana plugin validation c ## Building -### Using Go - ```bash # From the project root go build -o bin/mcpserver ./pkg/cmd/mcpserver @@ -14,25 +12,6 @@ go build -o bin/mcpserver ./pkg/cmd/mcpserver mage build:commands ``` -### Using Docker - -Build the binary using Docker (no Go installation required): - -```bash -# Build the Docker image (builds all binaries including mcpserver) -docker build -t plugin-validator-build . - -# Extract the mcpserver binary from the image -docker create --name temp-container plugin-validator-build -docker cp temp-container:/app/bin/linux_amd64/mcpserver ./mcpserver -docker rm temp-container - -# Move to installation directory -mkdir -p ~/.local/bin -mv ./mcpserver ~/.local/bin/plugin-validator-mcp -chmod +x ~/.local/bin/plugin-validator-mcp -``` - ## Installation ### Quick Install (Linux/macOS) From 3c1a00e82a1662c46c0991151f97142c35f470b5 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 12:56:17 -0500 Subject: [PATCH 08/22] chore: mcp server install script, add mcpserver to release --- .goreleaser.yaml | 11 +++++ pkg/cmd/mcpserver/README.md | 95 +++++++++++++++++++++++++++++++++++-- scripts/install-mcp.sh | 86 +++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) create mode 100755 scripts/install-mcp.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 10bf858f..7d08ae14 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,6 +18,17 @@ builds: env: - CGO_ENABLED=0 + - # MCP server + id: mcpserver + main: ./pkg/cmd/mcpserver + binary: plugin-validator-mcp + goos: + - linux + - windows + - darwin + env: + - CGO_ENABLED=0 + archives: - formats: [ tar.gz ] format_overrides: diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md index ee60b122..b0041921 100644 --- a/pkg/cmd/mcpserver/README.md +++ b/pkg/cmd/mcpserver/README.md @@ -14,10 +14,55 @@ mage build:commands ## Installation -### Quick Install (Linux/macOS) +### Quick Install from Release (Recommended) + +**Linux/macOS:** + +Run the installation script: ```bash -# Build and install to local bin +curl -fsSL https://raw.githubusercontent.com/grafana/plugin-validator/main/scripts/install-mcp.sh | bash +``` + +Or download and inspect the script first: + +```bash +wget https://raw.githubusercontent.com/grafana/plugin-validator/main/scripts/install-mcp.sh +chmod +x install-mcp.sh +./install-mcp.sh +``` + +**Windows (PowerShell):** + +```powershell +# Download latest release +$version = (Invoke-RestMethod "https://api.github.com/repos/grafana/plugin-validator/releases/latest").tag_name +$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" } +$url = "https://github.com/grafana/plugin-validator/releases/download/$version/plugin-validator_$($version.TrimStart('v'))_windows_$arch.zip" + +# Download and extract +Invoke-WebRequest -Uri $url -OutFile "$env:TEMP\plugin-validator.zip" +Expand-Archive -Path "$env:TEMP\plugin-validator.zip" -DestinationPath "$env:TEMP\plugin-validator" -Force + +# Move to user bin directory +$binDir = "$env:USERPROFILE\.local\bin" +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +Move-Item -Path "$env:TEMP\plugin-validator\plugin-validator-mcp.exe" -Destination "$binDir\" -Force + +# Add to PATH if not already present +if ($env:PATH -notlike "*$binDir*") { + [Environment]::SetEnvironmentVariable("PATH", "$env:PATH;$binDir", "User") +} +``` + +### Install via Go + +If you have Go installed and prefer building from source: + +```bash +# Clone and build +git clone https://github.com/grafana/plugin-validator.git +cd plugin-validator go build -o ~/.local/bin/plugin-validator-mcp ./pkg/cmd/mcpserver # Make sure ~/.local/bin is in your PATH @@ -26,6 +71,50 @@ export PATH="$HOME/.local/bin:$PATH" ## Configuration +### Claude Code (CLI & VS Code Extension) + +**Option 1: Global Configuration** + +Add to `~/.claude.json` (shared between CLI and VS Code extension): + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", + "args": [] + } + } +} +``` + +On macOS, use: +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp", + "args": [] + } + } +} +``` + +**Option 2: Project-Scoped** + +Create `.mcp.json` in your project root: + +```json +{ + "plugin-validator": { + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", + "args": [] + } +} +``` + +For more details on MCP server types and configuration, see [Claude Code Plugin Documentation](https://docs.anthropic.com/en/docs/claude-code). + ### Claude Desktop (macOS) Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -82,7 +171,7 @@ Edit `~/.cline/mcp_settings.json` (Linux/macOS): } } } -``` + ## Usage diff --git a/scripts/install-mcp.sh b/scripts/install-mcp.sh new file mode 100755 index 00000000..d79929ee --- /dev/null +++ b/scripts/install-mcp.sh @@ -0,0 +1,86 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "Installing Grafana Plugin Validator MCP Server..." + +# Detect OS and architecture +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case $ARCH in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + i386|i686) ARCH="386" ;; + *) + echo -e "${RED}Error: Unsupported architecture: $ARCH${NC}" + exit 1 + ;; +esac + +if [[ "$OS" != "linux" && "$OS" != "darwin" ]]; then + echo -e "${RED}Error: Unsupported OS: $OS${NC}" + echo "This script is for Linux and macOS. For Windows, see the README.md" + exit 1 +fi + +# Get latest release version +echo "Fetching latest release..." +VERSION=$(curl -s https://api.github.com/repos/grafana/plugin-validator/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4) + +if [ -z "$VERSION" ]; then + echo -e "${RED}Error: Could not fetch latest release version${NC}" + exit 1 +fi + +echo -e "${GREEN}Latest version: $VERSION${NC}" + +# Download release +DOWNLOAD_URL="https://github.com/grafana/plugin-validator/releases/download/${VERSION}/plugin-validator_${VERSION#v}_${OS}_${ARCH}.tar.gz" +echo "Downloading from: $DOWNLOAD_URL" + +if ! curl -fL "$DOWNLOAD_URL" -o /tmp/plugin-validator.tar.gz; then + echo -e "${RED}Error: Failed to download release${NC}" + exit 1 +fi + +# Extract MCP server binary +echo "Extracting plugin-validator-mcp binary..." +if ! tar -xzf /tmp/plugin-validator.tar.gz -C /tmp plugin-validator-mcp 2>/dev/null; then + echo -e "${RED}Error: Failed to extract binary. The MCP server might not be included in this release.${NC}" + echo -e "${YELLOW}Please ensure you're using version v0.38.0 or later, or build from source.${NC}" + rm -f /tmp/plugin-validator.tar.gz + exit 1 +fi + +# Install to ~/.local/bin +INSTALL_DIR="${HOME}/.local/bin" +mkdir -p "$INSTALL_DIR" +mv /tmp/plugin-validator-mcp "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/plugin-validator-mcp" +rm /tmp/plugin-validator.tar.gz + +echo -e "${GREEN}✓ Installed to $INSTALL_DIR/plugin-validator-mcp${NC}" + +# Check if ~/.local/bin is in PATH +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo -e "${YELLOW}Warning: $INSTALL_DIR is not in your PATH${NC}" + echo "Add the following to your ~/.bashrc or ~/.zshrc:" + echo "" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + echo "" +fi + +echo -e "${GREEN}Installation complete!${NC}" +echo "" +echo "Next steps:" +echo " 1. Configure the MCP server in your AI assistant (see README.md)" +echo " 2. Test the installation: plugin-validator-mcp" +echo "" +echo "For configuration examples, visit:" +echo " https://github.com/grafana/plugin-validator/blob/main/pkg/cmd/mcpserver/README.md" From 40b20f3607ff6c1abc1facdee572aa0849e968f0 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 21:40:57 -0500 Subject: [PATCH 09/22] ref: os.exit(1) when run returns err --- pkg/cmd/mcpserver/main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index 72d46b1b..2a2685c1 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -3,7 +3,7 @@ package main import ( "context" "fmt" - "log" + "os" "github.com/grafana/plugin-validator/pkg/analysis" "github.com/grafana/plugin-validator/pkg/service" @@ -57,6 +57,7 @@ func run() error { func main() { if err := run(); err != nil { - log.Fatalf("failed to run: %v", err) + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) } } From f4a7346de7965b26a61136cb4c1777487cf43f08 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Wed, 18 Feb 2026 09:10:40 -0500 Subject: [PATCH 10/22] ref: use validator cli in the mcp server --- pkg/cmd/mcpserver/main.go | 197 +++++++++++++++++++++++++--- pkg/cmd/mcpserver/main_test.go | 10 ++ pkg/cmd/plugincheck2/main.go | 182 ++++++++++++++++++++++++-- pkg/service/validator.go | 228 --------------------------------- 4 files changed, 361 insertions(+), 256 deletions(-) delete mode 100644 pkg/service/validator.go diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index 2a2685c1..250ff73c 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -2,52 +2,219 @@ package main import ( "context" + "encoding/json" "fmt" "os" + "os/exec" + "path/filepath" + "strings" "github.com/grafana/plugin-validator/pkg/analysis" - "github.com/grafana/plugin-validator/pkg/service" "github.com/modelcontextprotocol/go-sdk/mcp" ) type Input struct { PluginPath string `json:"pluginPath" jsonschema:"required,description=The path to the plugin directory. This can be a local file path or a URL. If it's a URL, it must be a zip file."` - SourceCodeUri string `json:"sourceCodeUri" jsonschema:"description=The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` + SourceCodeUri string `json:"sourceCodeUri,omitempty" jsonschema:"description=The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` +} + +type DiagnosticSummary struct { + TotalCategories int `json:"totalCategories" jsonschema:"description=Number of diagnostic categories checked."` + ErrorCount int `json:"errorCount" jsonschema:"description=Number of error-level issues found."` + WarningCount int `json:"warningCount" jsonschema:"description=Number of warning-level issues found."` + OkCount int `json:"okCount" jsonschema:"description=Number of checks that passed."` + SuspectedCount int `json:"suspectedCount" jsonschema:"description=Number of suspected/informational issues."` + TotalIssues int `json:"totalIssues" jsonschema:"description=Total number of all issues across all severity levels."` } type Output struct { - Diagnostics analysis.Diagnostics `json:"diagnostics" jsonschema:"description=The diagnostics results of the plugin validation. This includes errors, warnings, and recommendations for improving the plugin."` + PluginID string `json:"pluginId" jsonschema:"description=The plugin ID from plugin.json."` + Version string `json:"version" jsonschema:"description=The plugin version from plugin.json."` + Summary DiagnosticSummary `json:"summary" jsonschema:"description=Summary statistics of the validation results."` + Diagnostics analysis.Diagnostics `json:"diagnostics" jsonschema:"description=Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` + Passed bool `json:"passed" jsonschema:"description=True if validation passed (no errors), false otherwise."` +} + +type cliOutput struct { + ID string `json:"id"` + Version string `json:"version"` + PluginValidator analysis.Diagnostics `json:"plugin-validator"` +} + +func isDockerAvailable() bool { + _, err := exec.LookPath("docker") + return err == nil +} + +func isNpxAvailable() bool { + _, err := exec.LookPath("npx") + return err == nil } func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { - res, err := service.ValidatePlugin( - service.Params{ - PluginURL: input.PluginPath, - SourceCodeUri: input.SourceCodeUri, - }, - ) - if err != nil { - // Need to return diagnostics even in case of error, to provide feedback on what went wrong + var useDocker bool + var method string + + if isDockerAvailable() { + useDocker = true + method = "docker" + } else if isNpxAvailable() { + useDocker = false + method = "npx" + } else { + return nil, Output{}, fmt.Errorf("neither docker nor npx is available. Please install Docker or Node.js") + } + + var cmd *exec.Cmd + var pluginArg string + + // Handle local file paths - need to mount for Docker + // Check if it's a local file path (absolute, relative, or file:// URI) + isLocalFile := strings.HasPrefix(input.PluginPath, "/") || + strings.HasPrefix(input.PluginPath, "./") || + strings.HasPrefix(input.PluginPath, "../") || + strings.HasPrefix(input.PluginPath, "file://") || + (!strings.HasPrefix(input.PluginPath, "http://") && !strings.HasPrefix(input.PluginPath, "https://")) + + // Docker is preferred then npx as fallback + if useDocker { + args := []string{"run", "--pull=always", "--rm"} + + // Mount local files if needed + if isLocalFile { + localPath := strings.TrimPrefix(input.PluginPath, "file://") + absPath, err := filepath.Abs(localPath) + if err != nil { + return nil, Output{}, fmt.Errorf("failed to resolve path: %w", err) + } + // mounting the archive + args = append(args, "-v", fmt.Sprintf("%s:/archive.zip:ro", absPath)) + pluginArg = "/archive.zip" + } else { + pluginArg = input.PluginPath + } + + // Mount source code if provided and local + if input.SourceCodeUri != "" { + isLocalSource := strings.HasPrefix(input.SourceCodeUri, "/") || + strings.HasPrefix(input.SourceCodeUri, "./") || + strings.HasPrefix(input.SourceCodeUri, "../") || + strings.HasPrefix(input.SourceCodeUri, "file://") || + (!strings.HasPrefix(input.SourceCodeUri, "http://") && !strings.HasPrefix(input.SourceCodeUri, "https://")) + + if isLocalSource { + sourcePath := strings.TrimPrefix(input.SourceCodeUri, "file://") + absPath, err := filepath.Abs(sourcePath) + if err != nil { + return nil, Output{}, fmt.Errorf("failed to resolve source code path: %w", err) + } + // mounting the source code + args = append(args, "-v", fmt.Sprintf("%s:/source:ro", absPath)) + args = append(args, "grafana/plugin-validator-cli", "-jsonOutput", "-sourceCodeUri", "file:///source", pluginArg) + } else { + args = append(args, "grafana/plugin-validator-cli", "-jsonOutput", "-sourceCodeUri", input.SourceCodeUri, pluginArg) + } + } else { + args = append(args, "grafana/plugin-validator-cli", "-jsonOutput", pluginArg) + } + + cmd = exec.CommandContext(ctx, "docker", args...) + } else { + // Using npx + args := []string{"-y", "@grafana/plugin-validator@latest", "-jsonOutput"} + + if input.SourceCodeUri != "" { + args = append(args, "-sourceCodeUri", input.SourceCodeUri) + } + + args = append(args, input.PluginPath) + cmd = exec.CommandContext(ctx, "npx", args...) + } + + // Execute the command - capture stdout and stderr separately + var stdout, stderr []byte + var execErr error + + stdout, execErr = cmd.Output() + + // For exit errors (non-zero exit code), we may still have valid JSON output on stdout + // This is expected for validation failures + if execErr != nil { + if exitErr, ok := execErr.(*exec.ExitError); ok { + // exitErr.Stderr contains Docker pull messages or other stderr output + stderr = exitErr.Stderr + // stdout should already be captured above, even with non-zero exit + } else { + // Real error executing the command (command not found, etc.) + return nil, Output{}, fmt.Errorf("failed to execute validator via %s: %w", method, execErr) + } + } + + // Parse JSON output from stdout + var cliOut cliOutput + if err := json.Unmarshal(stdout, &cliOut); err != nil { + // If we can't parse the output, return a generic error diagnostic diagnostics := analysis.Diagnostics{ "validation": []analysis.Diagnostic{ { Name: "validation-error", Severity: analysis.Error, Title: "Plugin validation failed", - Detail: err.Error(), + Detail: fmt.Sprintf("Failed to parse validator output: %v\nStdout: %s\nStderr: %s", err, string(stdout), string(stderr)), }, }, } - return nil, Output{Diagnostics: diagnostics}, nil + return nil, Output{ + PluginID: "unknown", + Version: "unknown", + Diagnostics: diagnostics, + Summary: calculateSummary(diagnostics), + Passed: false, + }, nil } - return nil, Output{Diagnostics: res.Diagnostics}, nil + + // Calculate summary statistics + summary := calculateSummary(cliOut.PluginValidator) + + return nil, Output{ + PluginID: cliOut.ID, + Version: cliOut.Version, + Summary: summary, + Diagnostics: cliOut.PluginValidator, + Passed: summary.ErrorCount == 0, + }, nil +} + +// calculateSummary computes summary statistics from diagnostics +func calculateSummary(diags analysis.Diagnostics) DiagnosticSummary { + summary := DiagnosticSummary{ + TotalCategories: len(diags), + } + + for _, items := range diags { + for _, d := range items { + switch d.Severity { + case analysis.Error: + summary.ErrorCount++ + case analysis.Warning: + summary.WarningCount++ + case analysis.OK: + summary.OkCount++ + default: // "suspected" and others + summary.SuspectedCount++ + } + } + } + + summary.TotalIssues = summary.ErrorCount + summary.WarningCount + summary.SuspectedCount + return summary } func run() error { server := mcp.NewServer(&mcp.Implementation{Name: "plugin-validator", Version: "0.1.0"}, nil) mcp.AddTool(server, &mcp.Tool{ Name: "validate_plugin", - Description: "Validates a Grafana plugin against publishing requirements. Checks metadata, security, structure, and best practices. Returns detailed errors and warnings with actionable fix suggestions.", + Description: "Validates a Grafana plugin by calling the validator CLI via Docker (with --pull=always for latest) or npx. Checks metadata, security, structure, and best practices. Returns detailed errors and warnings with actionable fix suggestions.", }, ValidatePlugin) if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { return fmt.Errorf("failed to run server: %w", err) diff --git a/pkg/cmd/mcpserver/main_test.go b/pkg/cmd/mcpserver/main_test.go index 64beec6a..9b01105d 100644 --- a/pkg/cmd/mcpserver/main_test.go +++ b/pkg/cmd/mcpserver/main_test.go @@ -9,6 +9,11 @@ import ( ) func TestValidatePlugin_InvalidZip(t *testing.T) { + // Skip if neither docker nor npx is available (e.g., in CI/CD) + if !isDockerAvailable() && !isNpxAvailable() { + t.Skip("Skipping test: neither docker nor npx is available") + } + archivePath := filepath.Join("..", "plugincheck2", "testdata", "invalid.zip") input := Input{ PluginPath: archivePath, @@ -38,6 +43,11 @@ func TestValidatePlugin_InvalidZip(t *testing.T) { } func TestValidatePlugin_ValidZip(t *testing.T) { + // Skip if neither docker nor npx is available (e.g., in CI/CD) + if !isDockerAvailable() && !isNpxAvailable() { + t.Skip("Skipping test: neither docker nor npx is available") + } + archivePath := filepath.Join("..", "plugincheck2", "testdata", "alexanderzobnin-zabbix-app-4.4.9.linux_amd64.zip") input := Input{ PluginPath: archivePath, diff --git a/pkg/cmd/plugincheck2/main.go b/pkg/cmd/plugincheck2/main.go index da9ee79c..1ed8f1ea 100644 --- a/pkg/cmd/plugincheck2/main.go +++ b/pkg/cmd/plugincheck2/main.go @@ -1,17 +1,26 @@ package main import ( + "bytes" + "crypto/md5" + "crypto/sha1" "flag" "fmt" "io" "os" + "path/filepath" + "strings" + "github.com/bmatcuk/doublestar/v4" "gopkg.in/yaml.v3" + "github.com/grafana/plugin-validator/pkg/analysis" "github.com/grafana/plugin-validator/pkg/analysis/output" + "github.com/grafana/plugin-validator/pkg/analysis/passes" + "github.com/grafana/plugin-validator/pkg/archivetool" "github.com/grafana/plugin-validator/pkg/logme" + "github.com/grafana/plugin-validator/pkg/repotool" "github.com/grafana/plugin-validator/pkg/runner" - "github.com/grafana/plugin-validator/pkg/service" ) func main() { @@ -84,23 +93,106 @@ func main() { pluginURL := flag.Args()[0] - result, err := service.ValidatePlugin(service.Params{ - PluginURL: pluginURL, - SourceCodeUri: *sourceCodeUri, - Checksum: *checksum, - Analyzer: *analyzer, - AnalyzerSeverity: *analyzerSeverity, - Config: &cfg, - }) + // read archive file into bytes + b, err := archivetool.ReadArchive(pluginURL) if err != nil { - logme.Errorln(fmt.Errorf("couldn't validate plugin: %w", err)) + logme.Errorln(fmt.Errorf("couldn't fetch plugin archive: %w", err)) os.Exit(1) } - diags := result.Diagnostics - pluginID := result.PluginID - pluginVersion := result.PluginVersion + + // write archive to a temp file + tmpZip, err := os.CreateTemp("", "plugin-archive") + if err != nil { + logme.Errorln(fmt.Errorf("couldn't create temporary file: %w", err)) + os.Exit(1) + } + defer os.Remove(tmpZip.Name()) + + if _, err := tmpZip.Write(b); err != nil { + logme.Errorln(fmt.Errorf("couldn't write temporary file: %w", err)) + os.Exit(1) + } + + logme.Debugln(fmt.Sprintf("Archive copied to tmp file: %s", tmpZip.Name())) + + md5hasher := md5.New() + md5hasher.Write(b) + md5hash := md5hasher.Sum(nil) + + sha1hasher := sha1.New() + sha1hasher.Write(b) + sha1hash := sha1hasher.Sum(nil) + + logme.Debugln(fmt.Sprintf("ArchiveCalculatedMD5: %x", md5hash)) + logme.Debugln(fmt.Sprintf("ArchiveCalculatedSHA1: %x", sha1hash)) + + // Extract the ZIP archive in a temporary directory. + archiveDir, archiveCleanup, err := archivetool.ExtractPlugin(bytes.NewReader(b)) + if err != nil { + logme.Errorln(fmt.Errorf("couldn't extract plugin archive: %w", err)) + os.Exit(1) + } + defer archiveCleanup() + + sourceCodeDir, sourceCodeDirCleanup, err := getSourceCodeDir(*sourceCodeUri) + if err != nil { + // if source code is not provided, we don't fail the validation + logme.Errorln(fmt.Errorf("couldn't get source code: %w", err)) + } + if sourceCodeDirCleanup != nil { + defer sourceCodeDirCleanup() + } + + analyzers := passes.Analyzers + severity := analysis.Severity("") + + if *analyzer != "" { + for _, a := range analyzers { + if a.Name == *analyzer { + analyzers = []*analysis.Analyzer{a} + + break + } + } + if *analyzerSeverity != "" { + severity = analysis.Severity(*analyzerSeverity) + } + } + + diags, err := runner.Check( + analyzers, + analysis.CheckParams{ + ArchiveFile: tmpZip.Name(), + ArchiveDir: archiveDir, + SourceCodeDir: sourceCodeDir, + SourceCodeReference: *sourceCodeUri, + Checksum: *checksum, + ArchiveCalculatedMD5: fmt.Sprintf("%x", md5hash), + ArchiveCalculatedSHA1: fmt.Sprintf("%x", sha1hash), + }, + cfg, + severity, + ) + if err != nil { + // we don't exit on error. we want to still report the diagnostics + logme.DebugFln("check failed: %v", err) + } + var outputMarshaler output.Marshaler + // Plugin ID and version (needed by JSON output) + pluginID, pluginVersion, err := GetIDAndVersion(archiveDir) + if err != nil { + pluginID, pluginVersion = GetIDAndVersionFallBack(archiveDir) + archiveDiag := analysis.Diagnostic{ + Name: "zip-invalid", + Severity: analysis.Error, + Title: "Plugin archive is improperly structured", + Detail: "It is possible your plugin archive structure is incorrect. Please see https://grafana.com/developers/plugin-tools/publish-a-plugin/package-a-plugin for more information on how to package a plugin.", + } + diags["archive"] = append(diags["archive"], archiveDiag) + } + // Additional JSON output to file if *outputToFile != "" { ob, err := output.NewJSONMarshaler(pluginID, pluginVersion).Marshal(diags) @@ -185,3 +277,67 @@ func readConfigFile(path string) (runner.Config, error) { return config, nil } + +func getSourceCodeDirSubDir(sourceCodePath string) string { + // check if there's a package.json in the source code directory + // if so return the source code directory as is + if _, err := os.Stat(filepath.Join(sourceCodePath, "package.json")); err == nil { + return sourceCodePath + } + + // use double start to find the first ocurrance of package.json + possiblePath, err := doublestar.FilepathGlob(sourceCodePath + "/**/package.json") + if err != nil { + return sourceCodePath + } + if len(possiblePath) == 0 { + return sourceCodePath + } + logme.DebugFln( + "Detected sourcecode inside a subdir: %v. Returning %s", + possiblePath, + filepath.Dir(possiblePath[0]), + ) + // possiblePath points to a file, return the dir + return filepath.Dir(possiblePath[0]) +} + +func getSourceCodeDir(sourceCodeUri string) (string, func(), error) { + // If source code URI is not provided, return immediately with an empty string + // otherwise we will get an error when trying to extract the source code archive + if sourceCodeUri == "" { + return "", func() {}, nil + } + + // file:// protocol for local directories + if strings.HasPrefix(sourceCodeUri, "file://") { + sourceCodeDir := strings.TrimPrefix(sourceCodeUri, "file://") + if _, err := os.Stat(sourceCodeDir); err != nil { + return "", nil, err + } + return sourceCodeDir, func() {}, nil + } + + if repotool.IsSupportedGitUrl(sourceCodeUri) { + extractedGitRepo, sourceCodeCleanUp, err := repotool.GitUrlToLocalPath(sourceCodeUri) + if err != nil { + return "", sourceCodeCleanUp, err + } + return extractedGitRepo, sourceCodeCleanUp, nil + } + + // assume is an archive url + extractedDir, sourceCodeCleanUp, err := archivetool.ArchiveToLocalPath(sourceCodeUri) + if err != nil { + return "", sourceCodeCleanUp, fmt.Errorf( + "couldn't extract source code archive: %s. %w", + sourceCodeUri, + err, + ) + } + // some submissions from zip have their source code in a subdirectory + // of the extracted archive + extractedDir = getSourceCodeDirSubDir(extractedDir) + return extractedDir, sourceCodeCleanUp, nil + +} diff --git a/pkg/service/validator.go b/pkg/service/validator.go deleted file mode 100644 index 24ac9381..00000000 --- a/pkg/service/validator.go +++ /dev/null @@ -1,228 +0,0 @@ -package service - -import ( - "bytes" - "crypto/md5" - "crypto/sha1" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/bmatcuk/doublestar/v4" - "github.com/grafana/plugin-validator/pkg/analysis" - "github.com/grafana/plugin-validator/pkg/analysis/passes" - "github.com/grafana/plugin-validator/pkg/archivetool" - "github.com/grafana/plugin-validator/pkg/logme" - "github.com/grafana/plugin-validator/pkg/repotool" - "github.com/grafana/plugin-validator/pkg/runner" - "github.com/grafana/plugin-validator/pkg/utils" -) - -type Params struct { - PluginURL string - SourceCodeUri string - Checksum string - Analyzer string - AnalyzerSeverity string - Config *runner.Config -} - -type Result struct { - Diagnostics analysis.Diagnostics - PluginID string - PluginVersion string -} - -func ValidatePlugin(params Params) (Result, error) { - // read archive file into bytes - b, err := archivetool.ReadArchive(params.PluginURL) - if err != nil { - err = fmt.Errorf("couldn't read plugin archive: %w", err) - logme.Errorln(err) - return Result{}, err - } - - // write archive to a temp file - tmpZip, err := os.CreateTemp("", "plugin-archive") - if err != nil { - err = fmt.Errorf("couldn't create temporary file: %w", err) - logme.Errorln(err) - return Result{}, err - } - defer os.Remove(tmpZip.Name()) - - if _, err := tmpZip.Write(b); err != nil { - err = fmt.Errorf("couldn't write temporary file: %w", err) - logme.Errorln(err) - return Result{}, err - } - - logme.Debugln(fmt.Sprintf("Archive copied to tmp file: %s", tmpZip.Name())) - - md5hasher := md5.New() - md5hasher.Write(b) - md5hash := md5hasher.Sum(nil) - - sha1hasher := sha1.New() - sha1hasher.Write(b) - sha1hash := sha1hasher.Sum(nil) - - logme.Debugln(fmt.Sprintf("ArchiveCalculatedMD5: %x", md5hash)) - logme.Debugln(fmt.Sprintf("ArchiveCalculatedSHA1: %x", sha1hash)) - - // Extract the ZIP archive in a temporary directory. - archiveDir, archiveCleanup, err := archivetool.ExtractPlugin(bytes.NewReader(b)) - if err != nil { - err = fmt.Errorf("couldn't extract plugin archive: %w", err) - logme.Errorln(err) - return Result{}, err - } - defer archiveCleanup() - - sourceCodeDir, sourceCodeDirCleanup, err := getSourceCodeDir(params.SourceCodeUri) - if err != nil { - // if source code is not provided, we don't fail the validation - logme.Errorln(fmt.Errorf("couldn't get source code: %w", err)) - } - if sourceCodeDirCleanup != nil { - defer sourceCodeDirCleanup() - } - - analyzers := passes.Analyzers - severity := analysis.Severity("") - - if params.Analyzer != "" { - for _, a := range analyzers { - if a.Name == params.Analyzer { - analyzers = []*analysis.Analyzer{a} - - break - } - } - if params.AnalyzerSeverity != "" { - severity = analysis.Severity(params.AnalyzerSeverity) - } - } - - if params.Config == nil { - params.Config = &runner.Config{ - Global: runner.GlobalConfig{ - Enabled: true, - }, - } - } - - diags, err := runner.Check( - analyzers, - analysis.CheckParams{ - ArchiveFile: tmpZip.Name(), - ArchiveDir: archiveDir, - SourceCodeDir: sourceCodeDir, - SourceCodeReference: params.SourceCodeUri, - Checksum: params.Checksum, - ArchiveCalculatedMD5: fmt.Sprintf("%x", md5hash), - ArchiveCalculatedSHA1: fmt.Sprintf("%x", sha1hash), - }, - *params.Config, - severity, - ) - if err != nil { - // we don't exit on error. we want to still report the diagnostics - logme.DebugFln("check failed: %v", err) - } - - // Check if archive has proper structure (like old GetIDAndVersion did) - hasProperStructure := utils.HasProperArchiveStructure(archiveDir) - - // Try to get metadata using glob pattern - // finds the plugin.json file anywhere in the archive - metadata, metadataErr := utils.GetPluginMetadata(archiveDir) - - pluginID := "unknown" - pluginVersion := "unknown" - if metadataErr == nil { - pluginID = metadata.ID - pluginVersion = metadata.Info.Version - } - - // Add diagnostic if structure is improper (matching old behavior when GetIDAndVersion failed) - if !hasProperStructure { - archiveDiag := analysis.Diagnostic{ - Name: "zip-invalid", - Severity: analysis.Error, - Title: "Plugin archive is improperly structured", - Detail: "It is possible your plugin archive structure is incorrect. Please see https://grafana.com/developers/plugin-tools/publish-a-plugin/package-a-plugin for more information on how to package a plugin.", - } - diags["archive"] = append(diags["archive"], archiveDiag) - } - - return Result{ - Diagnostics: diags, - PluginID: pluginID, - PluginVersion: pluginVersion, - }, nil -} - -func getSourceCodeDirSubDir(sourceCodePath string) string { - // check if there's a package.json in the source code directory - // if so return the source code directory as is - if _, err := os.Stat(filepath.Join(sourceCodePath, "package.json")); err == nil { - return sourceCodePath - } - - // use double start to find the first ocurrance of package.json - possiblePath, err := doublestar.FilepathGlob(sourceCodePath + "/**/package.json") - if err != nil { - return sourceCodePath - } - if len(possiblePath) == 0 { - return sourceCodePath - } - logme.DebugFln( - "Detected sourcecode inside a subdir: %v. Returning %s", - possiblePath, - filepath.Dir(possiblePath[0]), - ) - // possiblePath points to a file, return the dir - return filepath.Dir(possiblePath[0]) -} - -func getSourceCodeDir(sourceCodeUri string) (string, func(), error) { - // If source code URI is not provided, return immediately with an empty string - // otherwise we will get an error when trying to extract the source code archive - if sourceCodeUri == "" { - return "", func() {}, nil - } - - // file:// protocol for local directories - if strings.HasPrefix(sourceCodeUri, "file://") { - sourceCodeDir := strings.TrimPrefix(sourceCodeUri, "file://") - if _, err := os.Stat(sourceCodeDir); err != nil { - return "", nil, err - } - return sourceCodeDir, func() {}, nil - } - - if repotool.IsSupportedGitUrl(sourceCodeUri) { - extractedGitRepo, sourceCodeCleanUp, err := repotool.GitUrlToLocalPath(sourceCodeUri) - if err != nil { - return "", sourceCodeCleanUp, err - } - return extractedGitRepo, sourceCodeCleanUp, nil - } - - // assume is an archive url - extractedDir, sourceCodeCleanUp, err := archivetool.ArchiveToLocalPath(sourceCodeUri) - if err != nil { - return "", sourceCodeCleanUp, fmt.Errorf( - "couldn't extract source code archive: %s. %w", - sourceCodeUri, - err, - ) - } - // some submissions from zip have their source code in a subdirectory - // of the extracted archive - extractedDir = getSourceCodeDirSubDir(extractedDir) - return extractedDir, sourceCodeCleanUp, nil -} From ab1bdb8664bb0069d3de44531c135c93000cac7f Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Wed, 18 Feb 2026 09:40:52 -0500 Subject: [PATCH 11/22] ref: remove analysis dependency --- pkg/analysis/analysis.go | 22 ++++++++--------- pkg/cmd/mcpserver/main.go | 50 ++++++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/pkg/analysis/analysis.go b/pkg/analysis/analysis.go index 27f41d2c..09419a74 100644 --- a/pkg/analysis/analysis.go +++ b/pkg/analysis/analysis.go @@ -17,12 +17,12 @@ var ( ) type Pass struct { - AnalyzerName string - RootDir string - CheckParams CheckParams - ResultOf map[*Analyzer]any - Report func(string, Diagnostic) - Diagnostics *Diagnostics + AnalyzerName string + RootDir string + CheckParams CheckParams + ResultOf map[*Analyzer]any + Report func(string, Diagnostic) + Diagnostics *Diagnostics } type CheckParams struct { @@ -85,11 +85,11 @@ func (p *Pass) AnalyzerHasErrors(a *Analyzer) bool { } type Diagnostic struct { - Severity Severity `json:"Severity" jsonschema:"description=The severity level of the issue."` - Title string `json:"Title" jsonschema:"description=A short, human-readable summary of the issue."` - Detail string `json:"Detail" jsonschema:"description=A detailed description of the issue."` - Context string `json:"Context,omitempty" jsonschema:"description=Additional context about the issue."` - Name string `json:"Name" jsonschema:"description=The name of the analysis that was run."` + Severity Severity + Title string + Detail string + Context string `json:"Context,omitempty"` + Name string } type Diagnostics map[string][]Diagnostic diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index 250ff73c..7cf34de9 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -9,10 +9,28 @@ import ( "path/filepath" "strings" - "github.com/grafana/plugin-validator/pkg/analysis" "github.com/modelcontextprotocol/go-sdk/mcp" ) +// Diagnostic represents a single validation issue +type Diagnostic struct { + Severity string `json:"Severity"` + Title string `json:"Title"` + Detail string `json:"Detail"` + Name string `json:"Name"` +} + +// Diagnostics is a map of category name to list of diagnostics +type Diagnostics map[string][]Diagnostic + +// Severity constants +const ( + SeverityError = "error" + SeverityWarning = "warning" + SeverityOK = "ok" + SeveritySuspected = "suspected" +) + type Input struct { PluginPath string `json:"pluginPath" jsonschema:"required,description=The path to the plugin directory. This can be a local file path or a URL. If it's a URL, it must be a zip file."` SourceCodeUri string `json:"sourceCodeUri,omitempty" jsonschema:"description=The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` @@ -28,17 +46,17 @@ type DiagnosticSummary struct { } type Output struct { - PluginID string `json:"pluginId" jsonschema:"description=The plugin ID from plugin.json."` - Version string `json:"version" jsonschema:"description=The plugin version from plugin.json."` - Summary DiagnosticSummary `json:"summary" jsonschema:"description=Summary statistics of the validation results."` - Diagnostics analysis.Diagnostics `json:"diagnostics" jsonschema:"description=Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` - Passed bool `json:"passed" jsonschema:"description=True if validation passed (no errors), false otherwise."` + PluginID string `json:"pluginId" jsonschema:"description=The plugin ID from plugin.json."` + Version string `json:"version" jsonschema:"description=The plugin version from plugin.json."` + Summary DiagnosticSummary `json:"summary" jsonschema:"description=Summary statistics of the validation results."` + Diagnostics Diagnostics `json:"diagnostics" jsonschema:"description=Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` + Passed bool `json:"passed" jsonschema:"description=True if validation passed (no errors), false otherwise."` } type cliOutput struct { - ID string `json:"id"` - Version string `json:"version"` - PluginValidator analysis.Diagnostics `json:"plugin-validator"` + ID string `json:"id"` + Version string `json:"version"` + PluginValidator Diagnostics `json:"plugin-validator"` } func isDockerAvailable() bool { @@ -154,11 +172,11 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) var cliOut cliOutput if err := json.Unmarshal(stdout, &cliOut); err != nil { // If we can't parse the output, return a generic error diagnostic - diagnostics := analysis.Diagnostics{ - "validation": []analysis.Diagnostic{ + diagnostics := Diagnostics{ + "validation": []Diagnostic{ { Name: "validation-error", - Severity: analysis.Error, + Severity: SeverityError, Title: "Plugin validation failed", Detail: fmt.Sprintf("Failed to parse validator output: %v\nStdout: %s\nStderr: %s", err, string(stdout), string(stderr)), }, @@ -186,7 +204,7 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) } // calculateSummary computes summary statistics from diagnostics -func calculateSummary(diags analysis.Diagnostics) DiagnosticSummary { +func calculateSummary(diags Diagnostics) DiagnosticSummary { summary := DiagnosticSummary{ TotalCategories: len(diags), } @@ -194,11 +212,11 @@ func calculateSummary(diags analysis.Diagnostics) DiagnosticSummary { for _, items := range diags { for _, d := range items { switch d.Severity { - case analysis.Error: + case SeverityError: summary.ErrorCount++ - case analysis.Warning: + case SeverityWarning: summary.WarningCount++ - case analysis.OK: + case SeverityOK: summary.OkCount++ default: // "suspected" and others summary.SuspectedCount++ From 7856e55064cd4fe93c86da8d3dbe0b83536251e3 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Wed, 18 Feb 2026 11:56:24 -0500 Subject: [PATCH 12/22] fix: jsonschema tag fix --- pkg/cmd/mcpserver/main.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index 7cf34de9..df25e33d 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -32,25 +32,25 @@ const ( ) type Input struct { - PluginPath string `json:"pluginPath" jsonschema:"required,description=The path to the plugin directory. This can be a local file path or a URL. If it's a URL, it must be a zip file."` - SourceCodeUri string `json:"sourceCodeUri,omitempty" jsonschema:"description=The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` + PluginPath string `json:"pluginPath" jsonschema:"required" jsonschema_description:"The path to the plugin directory. This can be a local file path or a URL. If it's a URL, it must be a zip file."` + SourceCodeUri string `json:"sourceCodeUri,omitempty" jsonschema_description:"The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` } type DiagnosticSummary struct { - TotalCategories int `json:"totalCategories" jsonschema:"description=Number of diagnostic categories checked."` - ErrorCount int `json:"errorCount" jsonschema:"description=Number of error-level issues found."` - WarningCount int `json:"warningCount" jsonschema:"description=Number of warning-level issues found."` - OkCount int `json:"okCount" jsonschema:"description=Number of checks that passed."` - SuspectedCount int `json:"suspectedCount" jsonschema:"description=Number of suspected/informational issues."` - TotalIssues int `json:"totalIssues" jsonschema:"description=Total number of all issues across all severity levels."` + TotalCategories int `json:"totalCategories" jsonschema_description:"Number of diagnostic categories checked."` + ErrorCount int `json:"errorCount" jsonschema_description:"Number of error-level issues found."` + WarningCount int `json:"warningCount" jsonschema_description:"Number of warning-level issues found."` + OkCount int `json:"okCount" jsonschema_description:"Number of checks that passed."` + SuspectedCount int `json:"suspectedCount" jsonschema_description:"Number of suspected/informational issues."` + TotalIssues int `json:"totalIssues" jsonschema_description:"Total number of all issues across all severity levels."` } type Output struct { - PluginID string `json:"pluginId" jsonschema:"description=The plugin ID from plugin.json."` - Version string `json:"version" jsonschema:"description=The plugin version from plugin.json."` - Summary DiagnosticSummary `json:"summary" jsonschema:"description=Summary statistics of the validation results."` - Diagnostics Diagnostics `json:"diagnostics" jsonschema:"description=Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` - Passed bool `json:"passed" jsonschema:"description=True if validation passed (no errors), false otherwise."` + PluginID string `json:"pluginId" jsonschema_description:"The plugin ID from plugin.json."` + Version string `json:"version" jsonschema_description:"The plugin version from plugin.json."` + Summary DiagnosticSummary `json:"summary" jsonschema_description:"Summary statistics of the validation results."` + Diagnostics Diagnostics `json:"diagnostics" jsonschema_description:"Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` + Passed bool `json:"passed" jsonschema_description:"True if validation passed (no errors), false otherwise."` } type cliOutput struct { From a1c63be2643662c67a5be5fcf7ef6ff71e1f5632 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Wed, 18 Feb 2026 14:09:46 -0500 Subject: [PATCH 13/22] chore: readme config changes --- pkg/cmd/mcpserver/README.md | 88 ++++++++++++++++++------------------- pkg/cmd/mcpserver/main.go | 16 +++++++ 2 files changed, 58 insertions(+), 46 deletions(-) diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md index b0041921..89b91acf 100644 --- a/pkg/cmd/mcpserver/README.md +++ b/pkg/cmd/mcpserver/README.md @@ -32,29 +32,6 @@ chmod +x install-mcp.sh ./install-mcp.sh ``` -**Windows (PowerShell):** - -```powershell -# Download latest release -$version = (Invoke-RestMethod "https://api.github.com/repos/grafana/plugin-validator/releases/latest").tag_name -$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" } -$url = "https://github.com/grafana/plugin-validator/releases/download/$version/plugin-validator_$($version.TrimStart('v'))_windows_$arch.zip" - -# Download and extract -Invoke-WebRequest -Uri $url -OutFile "$env:TEMP\plugin-validator.zip" -Expand-Archive -Path "$env:TEMP\plugin-validator.zip" -DestinationPath "$env:TEMP\plugin-validator" -Force - -# Move to user bin directory -$binDir = "$env:USERPROFILE\.local\bin" -New-Item -ItemType Directory -Force -Path $binDir | Out-Null -Move-Item -Path "$env:TEMP\plugin-validator\plugin-validator-mcp.exe" -Destination "$binDir\" -Force - -# Add to PATH if not already present -if ($env:PATH -notlike "*$binDir*") { - [Environment]::SetEnvironmentVariable("PATH", "$env:PATH;$binDir", "User") -} -``` - ### Install via Go If you have Go installed and prefer building from source: @@ -71,7 +48,7 @@ export PATH="$HOME/.local/bin:$PATH" ## Configuration -### Claude Code (CLI & VS Code Extension) +### Claude Code (CLI & VS Code Extension - Claude code chat) **Option 1: Global Configuration** @@ -89,6 +66,7 @@ Add to `~/.claude.json` (shared between CLI and VS Code extension): ``` On macOS, use: + ```json { "mcpServers": { @@ -115,76 +93,94 @@ Create `.mcp.json` in your project root: For more details on MCP server types and configuration, see [Claude Code Plugin Documentation](https://docs.anthropic.com/en/docs/claude-code). -### Claude Desktop (macOS) +### VS Code Extensions -Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: +This MCP server is compatible with any VS Code extension that supports the Model Context Protocol. Below are configurations for popular extensions: + +#### GitHub Copilot Chat + +Check the [GitHub Copilot documentation](https://docs.github.com/en/copilot) for MCP server configuration. If GitHub Copilot supports MCP in your version, you can typically configure it via `.vscode/mcp.json` in your project: ```json { - "mcpServers": { + "servers": { "plugin-validator": { - "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp" + "type": "stdio", + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", + "args": [] } } } ``` -### Claude Desktop (Linux) +**Note:** MCP support in GitHub Copilot may vary by version. Check your extension's documentation for the exact configuration format. -Edit `~/.config/Claude/claude_desktop_config.json`: +#### Continue + +Continue supports MCP servers. Edit `~/.continue/config.json`: ```json { - "mcpServers": { - "plugin-validator": { - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" - } + "experimental": { + "modelContextProtocolServers": [ + { + "transport": { + "type": "stdio", + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" + } + } + ] } } ``` -### VS Code with Continue Extension +See [Continue MCP Documentation](https://docs.continue.dev/features/model-context-protocol) for details. -Edit `~/.continue/config.json` (Linux/macOS): +### Claude Desktop (macOS) + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { - "mcpServers": [ - { - "name": "plugin-validator", - "command": "~/.local/bin/plugin-validator-mcp" + "mcpServers": { + "plugin-validator": { + "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp" } - ] + } } ``` -### Cline (VS Code Extension) +### Claude Desktop (Linux) -Edit `~/.cline/mcp_settings.json` (Linux/macOS): +Edit `~/.config/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "plugin-validator": { - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", - "args": [] + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" } } } - +``` ## Usage Once configured, you can ask your AI assistant to validate Grafana plugins: ``` + Validate this Grafana plugin: /path/to/plugin.zip + ``` ``` + Check this plugin with source code: + - Plugin: ./my-plugin.zip - Source: https://github.com/user/my-plugin + ``` ## Tool Details diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index df25e33d..20f72214 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "os" "os/exec" "path/filepath" @@ -70,15 +71,19 @@ func isNpxAvailable() bool { } func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { + log.Printf("[MCP] ValidatePlugin called - pluginPath: %s, sourceCodeUri: %s", input.PluginPath, input.SourceCodeUri) + var useDocker bool var method string if isDockerAvailable() { useDocker = true method = "docker" + log.Printf("[MCP] Using Docker for validation") } else if isNpxAvailable() { useDocker = false method = "npx" + log.Printf("[MCP] Using npx for validation") } else { return nil, Output{}, fmt.Errorf("neither docker nor npx is available. Please install Docker or Node.js") } @@ -137,6 +142,7 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) } cmd = exec.CommandContext(ctx, "docker", args...) + log.Printf("[MCP] Executing: docker %v", args) } else { // Using npx args := []string{"-y", "@grafana/plugin-validator@latest", "-jsonOutput"} @@ -147,6 +153,7 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) args = append(args, input.PluginPath) cmd = exec.CommandContext(ctx, "npx", args...) + log.Printf("[MCP] Executing: npx %v", args) } // Execute the command - capture stdout and stderr separately @@ -169,8 +176,11 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) } // Parse JSON output from stdout + log.Printf("[MCP] Command completed, stdout length: %d, stderr length: %d", len(stdout), len(stderr)) + var cliOut cliOutput if err := json.Unmarshal(stdout, &cliOut); err != nil { + log.Printf("[MCP] Failed to parse JSON: %v", err) // If we can't parse the output, return a generic error diagnostic diagnostics := Diagnostics{ "validation": []Diagnostic{ @@ -193,6 +203,8 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) // Calculate summary statistics summary := calculateSummary(cliOut.PluginValidator) + log.Printf("[MCP] Validation complete - PluginID: %s, Version: %s, Errors: %d, Warnings: %d", + cliOut.ID, cliOut.Version, summary.ErrorCount, summary.WarningCount) return nil, Output{ PluginID: cliOut.ID, @@ -229,6 +241,10 @@ func calculateSummary(diags Diagnostics) DiagnosticSummary { } func run() error { + log.SetOutput(os.Stderr) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.Printf("[MCP] Starting plugin-validator MCP server v0.1.0") + server := mcp.NewServer(&mcp.Implementation{Name: "plugin-validator", Version: "0.1.0"}, nil) mcp.AddTool(server, &mcp.Tool{ Name: "validate_plugin", From 61a52597a907df129f9ee1c47959a2f2720c82d1 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Thu, 19 Feb 2026 08:43:24 -0500 Subject: [PATCH 14/22] ref: remove unused func --- pkg/utils/utils.go | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 3d436156..851015f3 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "github.com/bmatcuk/doublestar/v4" "github.com/grafana/plugin-validator/pkg/analysis/passes/metadata" @@ -66,25 +65,3 @@ func GetPluginMetadata(archiveDir string) (*metadata.Metadata, error) { } return &pluginJson, nil } - -// HasProperArchiveStructure checks if the archive has the proper structure: -// single top-level directory containing plugin.json -func HasProperArchiveStructure(archiveDir string) bool { - fis, err := os.ReadDir(archiveDir) - if err != nil || len(fis) == 0 { - return false - } - - // Check if first entry is a directory - if !fis[0].IsDir() { - return false - } - - // Check if plugin.json exists in that directory - pluginJsonPath := filepath.Join(archiveDir, fis[0].Name(), "plugin.json") - if _, err := os.Stat(pluginJsonPath); err != nil { - return false - } - - return true -} From 317e8399d84bd76d86e7e67f6128d9e9b3cb75f4 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Thu, 19 Feb 2026 15:41:18 -0500 Subject: [PATCH 15/22] ref: remove calculate summary, pass the version --- pkg/cmd/mcpserver/README.md | 236 ------------------------------------ pkg/cmd/mcpserver/main.go | 82 +++---------- 2 files changed, 17 insertions(+), 301 deletions(-) delete mode 100644 pkg/cmd/mcpserver/README.md diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md deleted file mode 100644 index 89b91acf..00000000 --- a/pkg/cmd/mcpserver/README.md +++ /dev/null @@ -1,236 +0,0 @@ -# Plugin Validator MCP Server - -An MCP (Model Context Protocol) server that provides Grafana plugin validation capabilities to AI assistants and code editors. - -## Building - -```bash -# From the project root -go build -o bin/mcpserver ./pkg/cmd/mcpserver - -# Or using mage -mage build:commands -``` - -## Installation - -### Quick Install from Release (Recommended) - -**Linux/macOS:** - -Run the installation script: - -```bash -curl -fsSL https://raw.githubusercontent.com/grafana/plugin-validator/main/scripts/install-mcp.sh | bash -``` - -Or download and inspect the script first: - -```bash -wget https://raw.githubusercontent.com/grafana/plugin-validator/main/scripts/install-mcp.sh -chmod +x install-mcp.sh -./install-mcp.sh -``` - -### Install via Go - -If you have Go installed and prefer building from source: - -```bash -# Clone and build -git clone https://github.com/grafana/plugin-validator.git -cd plugin-validator -go build -o ~/.local/bin/plugin-validator-mcp ./pkg/cmd/mcpserver - -# Make sure ~/.local/bin is in your PATH -export PATH="$HOME/.local/bin:$PATH" -``` - -## Configuration - -### Claude Code (CLI & VS Code Extension - Claude code chat) - -**Option 1: Global Configuration** - -Add to `~/.claude.json` (shared between CLI and VS Code extension): - -```json -{ - "mcpServers": { - "plugin-validator": { - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", - "args": [] - } - } -} -``` - -On macOS, use: - -```json -{ - "mcpServers": { - "plugin-validator": { - "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp", - "args": [] - } - } -} -``` - -**Option 2: Project-Scoped** - -Create `.mcp.json` in your project root: - -```json -{ - "plugin-validator": { - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", - "args": [] - } -} -``` - -For more details on MCP server types and configuration, see [Claude Code Plugin Documentation](https://docs.anthropic.com/en/docs/claude-code). - -### VS Code Extensions - -This MCP server is compatible with any VS Code extension that supports the Model Context Protocol. Below are configurations for popular extensions: - -#### GitHub Copilot Chat - -Check the [GitHub Copilot documentation](https://docs.github.com/en/copilot) for MCP server configuration. If GitHub Copilot supports MCP in your version, you can typically configure it via `.vscode/mcp.json` in your project: - -```json -{ - "servers": { - "plugin-validator": { - "type": "stdio", - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", - "args": [] - } - } -} -``` - -**Note:** MCP support in GitHub Copilot may vary by version. Check your extension's documentation for the exact configuration format. - -#### Continue - -Continue supports MCP servers. Edit `~/.continue/config.json`: - -```json -{ - "experimental": { - "modelContextProtocolServers": [ - { - "transport": { - "type": "stdio", - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" - } - } - ] - } -} -``` - -See [Continue MCP Documentation](https://docs.continue.dev/features/model-context-protocol) for details. - -### Claude Desktop (macOS) - -Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "plugin-validator": { - "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp" - } - } -} -``` - -### Claude Desktop (Linux) - -Edit `~/.config/Claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "plugin-validator": { - "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" - } - } -} -``` - -## Usage - -Once configured, you can ask your AI assistant to validate Grafana plugins: - -``` - -Validate this Grafana plugin: /path/to/plugin.zip - -``` - -``` - -Check this plugin with source code: - -- Plugin: ./my-plugin.zip -- Source: https://github.com/user/my-plugin - -``` - -## Tool Details - -### validate_plugin - -Validates a Grafana plugin against publishing requirements. - -**Inputs:** - -- `pluginPath` (required): Path or URL to the plugin archive (.zip) -- `sourceCodeUri` (optional): Path or URL to plugin source code (zip, folder, or git repo) - -**Output:** - -- `diagnostics`: Structured validation results with errors, warnings, and recommendations - -## Troubleshooting - -### Server not found - -Make sure the binary path is correct: - -```bash -which plugin-validator-mcp -# or -ls -la ~/.local/bin/plugin-validator-mcp -``` - -### Permission denied - -Make the binary executable: - -```bash -chmod +x ~/.local/bin/plugin-validator-mcp -``` - -### Test manually - -Run the server directly to check for errors: - -```bash -~/.local/bin/plugin-validator-mcp -# Press Ctrl+C to exit -``` - -## Development - -Run tests: - -```bash -go test ./pkg/cmd/mcpserver -v -``` diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index 20f72214..e545e046 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -24,6 +24,8 @@ type Diagnostic struct { // Diagnostics is a map of category name to list of diagnostics type Diagnostics map[string][]Diagnostic +var version = "dev" + // Severity constants const ( SeverityError = "error" @@ -37,21 +39,10 @@ type Input struct { SourceCodeUri string `json:"sourceCodeUri,omitempty" jsonschema_description:"The URI of the source code. This can be a local file path (zip or folder) or a URL. If it's a URL, it must be a git repository or a zip file."` } -type DiagnosticSummary struct { - TotalCategories int `json:"totalCategories" jsonschema_description:"Number of diagnostic categories checked."` - ErrorCount int `json:"errorCount" jsonschema_description:"Number of error-level issues found."` - WarningCount int `json:"warningCount" jsonschema_description:"Number of warning-level issues found."` - OkCount int `json:"okCount" jsonschema_description:"Number of checks that passed."` - SuspectedCount int `json:"suspectedCount" jsonschema_description:"Number of suspected/informational issues."` - TotalIssues int `json:"totalIssues" jsonschema_description:"Total number of all issues across all severity levels."` -} - type Output struct { - PluginID string `json:"pluginId" jsonschema_description:"The plugin ID from plugin.json."` - Version string `json:"version" jsonschema_description:"The plugin version from plugin.json."` - Summary DiagnosticSummary `json:"summary" jsonschema_description:"Summary statistics of the validation results."` - Diagnostics Diagnostics `json:"diagnostics" jsonschema_description:"Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` - Passed bool `json:"passed" jsonschema_description:"True if validation passed (no errors), false otherwise."` + PluginID string `json:"pluginId" jsonschema_description:"The plugin ID from plugin.json."` + Version string `json:"version" jsonschema_description:"The plugin version from plugin.json."` + Diagnostics Diagnostics `json:"diagnostics" jsonschema_description:"Detailed diagnostics grouped by category (e.g., archive, manifest, security). Each category contains a list of issues with Severity (error/warning/ok/suspected), Title (brief description), Detail (detailed explanation), and Name (machine-readable identifier)."` } type cliOutput struct { @@ -70,6 +61,14 @@ func isNpxAvailable() bool { return err == nil } +func isLocalFilePath(path string) bool { + return strings.HasPrefix(path, "/") || + strings.HasPrefix(path, "./") || + strings.HasPrefix(path, "../") || + strings.HasPrefix(path, "file://") || + (!strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://")) +} + func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { log.Printf("[MCP] ValidatePlugin called - pluginPath: %s, sourceCodeUri: %s", input.PluginPath, input.SourceCodeUri) @@ -91,20 +90,12 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) var cmd *exec.Cmd var pluginArg string - // Handle local file paths - need to mount for Docker - // Check if it's a local file path (absolute, relative, or file:// URI) - isLocalFile := strings.HasPrefix(input.PluginPath, "/") || - strings.HasPrefix(input.PluginPath, "./") || - strings.HasPrefix(input.PluginPath, "../") || - strings.HasPrefix(input.PluginPath, "file://") || - (!strings.HasPrefix(input.PluginPath, "http://") && !strings.HasPrefix(input.PluginPath, "https://")) - // Docker is preferred then npx as fallback if useDocker { args := []string{"run", "--pull=always", "--rm"} // Mount local files if needed - if isLocalFile { + if isLocalFilePath(input.PluginPath) { localPath := strings.TrimPrefix(input.PluginPath, "file://") absPath, err := filepath.Abs(localPath) if err != nil { @@ -119,13 +110,8 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) // Mount source code if provided and local if input.SourceCodeUri != "" { - isLocalSource := strings.HasPrefix(input.SourceCodeUri, "/") || - strings.HasPrefix(input.SourceCodeUri, "./") || - strings.HasPrefix(input.SourceCodeUri, "../") || - strings.HasPrefix(input.SourceCodeUri, "file://") || - (!strings.HasPrefix(input.SourceCodeUri, "http://") && !strings.HasPrefix(input.SourceCodeUri, "https://")) - if isLocalSource { + if isLocalFilePath(input.SourceCodeUri) { sourcePath := strings.TrimPrefix(input.SourceCodeUri, "file://") absPath, err := filepath.Abs(sourcePath) if err != nil { @@ -196,56 +182,22 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) PluginID: "unknown", Version: "unknown", Diagnostics: diagnostics, - Summary: calculateSummary(diagnostics), - Passed: false, }, nil } - // Calculate summary statistics - summary := calculateSummary(cliOut.PluginValidator) - log.Printf("[MCP] Validation complete - PluginID: %s, Version: %s, Errors: %d, Warnings: %d", - cliOut.ID, cliOut.Version, summary.ErrorCount, summary.WarningCount) - return nil, Output{ PluginID: cliOut.ID, Version: cliOut.Version, - Summary: summary, Diagnostics: cliOut.PluginValidator, - Passed: summary.ErrorCount == 0, }, nil } -// calculateSummary computes summary statistics from diagnostics -func calculateSummary(diags Diagnostics) DiagnosticSummary { - summary := DiagnosticSummary{ - TotalCategories: len(diags), - } - - for _, items := range diags { - for _, d := range items { - switch d.Severity { - case SeverityError: - summary.ErrorCount++ - case SeverityWarning: - summary.WarningCount++ - case SeverityOK: - summary.OkCount++ - default: // "suspected" and others - summary.SuspectedCount++ - } - } - } - - summary.TotalIssues = summary.ErrorCount + summary.WarningCount + summary.SuspectedCount - return summary -} - func run() error { log.SetOutput(os.Stderr) log.SetFlags(log.LstdFlags | log.Lmicroseconds) - log.Printf("[MCP] Starting plugin-validator MCP server v0.1.0") + log.Printf("[MCP] Starting plugin-validator MCP server v%s", version) - server := mcp.NewServer(&mcp.Implementation{Name: "plugin-validator", Version: "0.1.0"}, nil) + server := mcp.NewServer(&mcp.Implementation{Name: "plugin-validator", Version: version}, nil) mcp.AddTool(server, &mcp.Tool{ Name: "validate_plugin", Description: "Validates a Grafana plugin by calling the validator CLI via Docker (with --pull=always for latest) or npx. Checks metadata, security, structure, and best practices. Returns detailed errors and warnings with actionable fix suggestions.", From ea7555b56a231b9a3807a01b6ba4a065962778b1 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Thu, 19 Feb 2026 15:41:58 -0500 Subject: [PATCH 16/22] chore: create release workflows for mcp --- .github/workflows/do-release-mcp.yml | 69 ++++++++++++ .github/workflows/release-mcp.yml | 101 +++++++++++++++++ .github/workflows/release.yml | 2 +- .goreleaser.mcp.yaml | 40 +++++++ .goreleaser.yaml | 11 -- README.md | 12 +- mcp-package/Dockerfile | 22 ++++ mcp-package/README.md | 159 +++++++++++++++++++++++++++ mcp-package/index.js | 155 ++++++++++++++++++++++++++ mcp-package/package.json | 42 +++++++ scripts/install-mcp.sh | 86 --------------- 11 files changed, 591 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/do-release-mcp.yml create mode 100644 .github/workflows/release-mcp.yml create mode 100644 .goreleaser.mcp.yaml create mode 100644 mcp-package/Dockerfile create mode 100644 mcp-package/README.md create mode 100644 mcp-package/index.js create mode 100644 mcp-package/package.json delete mode 100755 scripts/install-mcp.sh diff --git a/.github/workflows/do-release-mcp.yml b/.github/workflows/do-release-mcp.yml new file mode 100644 index 00000000..2736daa4 --- /dev/null +++ b/.github/workflows/do-release-mcp.yml @@ -0,0 +1,69 @@ +name: Release MCP Server + +on: + workflow_dispatch: + inputs: + version: + description: "Semver type of new version (major / minor / patch)" + required: true + type: choice + options: + - patch + - minor + - major + +jobs: + release-mcp: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@a37de51f3d713a30a9e4b21bcdfbd38170020593 # v1.3.0 + with: + repo_secrets: | + GITHUB_APP_ID=plugins-platform-bot-app:app_id + GITHUB_APP_PRIVATE_KEY=plugins-platform-bot-app:app_pem + export_env: false + + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_ID }} + private-key: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_PRIVATE_KEY }} + permission-contents: write + + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + token: ${{ steps.generate_token.outputs.token }} + persist-credentials: true + + - name: Setup Git + run: | + git config user.name 'grafana-plugins-platform-bot[bot]' + git config user.email '144369747+grafana-plugins-platform-bot[bot]@users.noreply.github.com' + + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: "24" + + - name: Bump MCP version and create tag + run: | + cd mcp-package + npm version ${INPUT_VERSION} --no-git-tag-version + NEW_VERSION=$(jq -r .version package.json) + cd .. + + git add mcp-package/package.json + git commit -m "chore(mcp): release v${NEW_VERSION}" + git tag "mcp/v${NEW_VERSION}" + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + + - name: Push changes and tag + run: | + git push origin main + git push origin --tags diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml new file mode 100644 index 00000000..ef18c40e --- /dev/null +++ b/.github/workflows/release-mcp.yml @@ -0,0 +1,101 @@ +name: Create MCP release and publish to github, npm and docker hub + +on: # zizmor: ignore[cache-poisoning] + push: + tags: + - 'mcp/v[0-9]*' + +jobs: + release-mcp-to-github: + runs-on: ubuntu-arm64 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - run: git fetch --force --tags + - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version-file: go.mod + check-latest: true + + - id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@a37de51f3d713a30a9e4b21bcdfbd38170020593 # v1.3.0 + with: + repo_secrets: | + GITHUB_APP_ID=plugins-platform-bot-app:app_id + GITHUB_APP_PRIVATE_KEY=plugins-platform-bot-app:app_pem + export_env: false + + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_ID }} + private-key: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_PRIVATE_KEY }} + permission-contents: write + + - name: Extract MCP version + run: echo "MCP_VERSION=${GITHUB_REF_NAME#mcp/v}" >> $GITHUB_ENV + + - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: latest + args: release --clean --config .goreleaser.mcp.yaml --skip=validate + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + MCP_VERSION: ${{ env.MCP_VERSION }} + + release-mcp-to-npm: + runs-on: ubuntu-latest + needs: release-mcp-to-github + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - run: cd mcp-package && npm install + - run: cd mcp-package && npm publish + + release-mcp-to-dockerhub: + runs-on: ubuntu-x64 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Get version from package.json + id: get_version + run: | + echo "version=$(jq -r .version mcp-package/package.json)" >> "$GITHUB_OUTPUT" + + - id: push-mcp-to-dockerhub + uses: grafana/shared-workflows/actions/build-push-to-dockerhub@f02d5da7ddff4ea32bbe5034c7c70e90d2b9622c # build-push-to-dockerhub/v0.4.1 + with: + repository: grafana/plugin-validator-mcp + context: . + file: mcp-package/Dockerfile + build-args: | + MCP_VERSION=${{ steps.get_version.outputs.version }} + tags: |- + "v${{ steps.get_version.outputs.version }}" + "latest" + push: true + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc99842b..35be2e6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Create release and publish to github, npm and docker hub on: # zizmor: ignore[cache-poisoning] push: tags: - - "*" + - 'v[0-9]*' jobs: release-to-github: diff --git a/.goreleaser.mcp.yaml b/.goreleaser.mcp.yaml new file mode 100644 index 00000000..9c1aabea --- /dev/null +++ b/.goreleaser.mcp.yaml @@ -0,0 +1,40 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# Separate goreleaser config for the MCP server. +# Triggered by mcp/v* tags independently of the main plugin-validator releases. +# MCP_VERSION env var must be set to the bare semver (e.g. "0.2.0") by the workflow. +version: 2 + +project_name: plugin-validator-mcp + +before: + hooks: + - go mod tidy + +builds: + - id: mcpserver + main: ./pkg/cmd/mcpserver + binary: plugin-validator-mcp + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{ .Env.MCP_VERSION }} + +archives: + - name_template: "plugin-validator-mcp_{{ .Env.MCP_VERSION }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: "checksums.txt" + +changelog: + disable: true diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7d08ae14..10bf858f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,17 +18,6 @@ builds: env: - CGO_ENABLED=0 - - # MCP server - id: mcpserver - main: ./pkg/cmd/mcpserver - binary: plugin-validator-mcp - goos: - - linux - - windows - - darwin - env: - - CGO_ENABLED=0 - archives: - formats: [ tar.gz ] format_overrides: diff --git a/README.md b/README.md index 2a09047c..49315667 100644 --- a/README.md +++ b/README.md @@ -80,17 +80,9 @@ plugincheck2 -sourceCodeUri [source_code_location/] [plugin_archive.zip] ### MCP Server (for AI assistants) -The plugin validator can also be used as an MCP (Model Context Protocol) server, which allows AI assistants and code editors like Claude, VS Code with Continue, and Cline to validate Grafana plugins directly. +The plugin validator can be used as an MCP (Model Context Protocol) server, allowing AI assistants like Claude, Cline, and other MCP-compatible tools to validate Grafana plugins. -To build and use the MCP server: - -```SHELL -git clone git@github.com:grafana/plugin-validator.git -cd plugin-validator -go build -o ~/.local/bin/plugin-validator-mcp ./pkg/cmd/mcpserver -``` - -For detailed configuration instructions for different AI tools and editors, see the [MCP Server README](pkg/cmd/mcpserver/README.md). +See the [MCP Server README](mcp-package/README.md) for configuration instructions. ### Generating local files For validation diff --git a/mcp-package/Dockerfile b/mcp-package/Dockerfile new file mode 100644 index 00000000..b254f783 --- /dev/null +++ b/mcp-package/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.25-alpine3.22@sha256:fa3380ab0d73b706e6b07d2a306a4dc68f20bfc1437a6a6c47c8f88fe4af6f75 AS builder + +ARG MCP_VERSION=dev + +WORKDIR /build +COPY pkg/cmd/mcpserver ./pkg/cmd/mcpserver +COPY go.mod go.sum ./ + +RUN apk add --no-cache git ca-certificates && \ + update-ca-certificates && \ + CGO_ENABLED=0 GO111MODULE=on go build -ldflags "-s -w -X main.version=${MCP_VERSION}" -o plugin-validator-mcp ./pkg/cmd/mcpserver + +FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 + +RUN apk add --no-cache ca-certificates docker-cli nodejs npm && \ + update-ca-certificates + +WORKDIR /app +COPY --from=builder /build/plugin-validator-mcp /app/plugin-validator-mcp + +# MCP servers communicate over stdin/stdout +ENTRYPOINT ["/app/plugin-validator-mcp"] diff --git a/mcp-package/README.md b/mcp-package/README.md new file mode 100644 index 00000000..89c33222 --- /dev/null +++ b/mcp-package/README.md @@ -0,0 +1,159 @@ +# Grafana Plugin Validator MCP Server + +A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that provides AI assistants with the ability to validate Grafana plugins. + +## Configuration + +### Claude Code (CLI & VS Code Extension) + +**Using NPM (Recommended):** + +Add to `~/.claude.json` (shared between CLI and VS Code extension): + +```json +{ + "mcpServers": { + "grafana-plugin-validator": { + "command": "npx", + "args": ["-y", "@grafana/plugin-validator-mcp@latest"] + } + } +} +``` + +**Using Docker:** + +```json +{ + "mcpServers": { + "grafana-plugin-validator": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "grafana/plugin-validator-mcp:latest" + ] + } + } +} +``` + +**Project-Scoped Configuration:** + +Create `.mcp.json` in your project root: + +```json +{ + "grafana-plugin-validator": { + "command": "npx", + "args": ["-y", "@grafana/plugin-validator-mcp@latest"] + } +} +``` + +For more details on MCP server types and configuration, see [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code). + +### Cline (VS Code Extension) + +Add this to your Cline MCP settings: + +```json +{ + "mcpServers": { + "grafana-plugin-validator": { + "command": "npx", + "args": ["-y", "@grafana/plugin-validator-mcp@latest"] + } + } +} +``` + +### Other MCP Clients + +For other MCP-compatible AI assistants and editors, use: + +```bash +npx -y @grafana/plugin-validator-mcp@latest +``` + +### Claude Desktop + +**macOS**: Edit `~/Library/Application Support/Claude/claude_desktop_config.json` + +**Linux**: Edit `~/.config/Claude/claude_desktop_config.json` + +**Using NPM (Recommended):** + +```json +{ + "mcpServers": { + "grafana-plugin-validator": { + "command": "npx", + "args": ["-y", "@grafana/plugin-validator-mcp@latest"] + } + } +} +``` + +**Using Docker:** + +```json +{ + "mcpServers": { + "grafana-plugin-validator": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "grafana/plugin-validator-mcp:latest" + ] + } + } +} +``` + +## Usage + +Once configured, you can ask your AI assistant to validate Grafana plugins: + +``` +Validate this Grafana plugin: /path/to/plugin.zip +``` + +``` +Check this plugin with source code: +- Plugin: ./my-plugin.zip +- Source: https://github.com/user/my-plugin +``` + +## Available Tools + +The MCP server provides the following tool: + +### `validate_plugin` + +Validates a Grafana plugin and returns detailed diagnostics. + +**Parameters:** + +- `pluginPath` (required): Path or URL to the plugin archive (zip file) +- `sourceCodeUri` (optional): Path or URL to the plugin's source code for additional checks + +**Example:** + +```json +{ + "pluginPath": "https://github.com/example/my-plugin/releases/download/v1.0.0/my-plugin.zip", + "sourceCodeUri": "https://github.com/example/my-plugin" +} +``` + +## License + +Apache-2.0 License. See the [LICENSE](https://github.com/grafana/plugin-validator/blob/main/LICENSE) file for details. diff --git a/mcp-package/index.js b/mcp-package/index.js new file mode 100644 index 00000000..1b66c691 --- /dev/null +++ b/mcp-package/index.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const https = require("https"); +const tar = require("tar"); +const { spawn } = require("child_process"); + +const packageJson = require("./package.json"); +const version = packageJson.version; +const urlTemplate = packageJson.binWrapper.urlTemplate; +const binaryName = + process.platform === "win32" + ? packageJson.binWrapper.name + ".exe" + : packageJson.binWrapper.name; + +const downloadPath = path.join(__dirname, ".bin"); +const binaryPath = path.join(downloadPath, binaryName); + +const PLATFORM_MAPPING = { + win32: "windows", +}; + +const ARCH_MAPPING = { + ia32: "386", + x64: "amd64", + arm: "arm", + arm64: "arm64", +}; + +function getPlatformSpecificDownloadUrl(platform, arch) { + const finalPlatform = PLATFORM_MAPPING[platform] || platform; + const finalArch = ARCH_MAPPING[arch] || arch; + + return urlTemplate + .replaceAll("{{version}}", version) + .replaceAll("{{platform}}", finalPlatform) + .replaceAll("{{arch}}", finalArch); +} + +function downloadFile(fileUrl, outputFolder) { + const fileName = path.basename(new URL(fileUrl).pathname); + const outputPath = path.join(outputFolder, fileName); + + // Check if the file already exists + if (fs.existsSync(outputPath)) { + return Promise.resolve(outputPath); + } + + return new Promise((resolve, reject) => { + const download = (urlToDownload) => { + https + .get(urlToDownload, (response) => { + if ( + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + // Handle redirection + const redirectedUrl = new URL( + response.headers.location, + urlToDownload, + ).toString(); + download(redirectedUrl); + } else if (response.statusCode === 200) { + const fileStream = fs.createWriteStream(outputPath); + response.pipe(fileStream); + + fileStream.on("finish", () => { + fileStream.close(() => { + resolve(outputPath); + }); + }); + } else { + reject( + new Error( + `Failed to download '${fileUrl}' (${response.statusCode})`, + ), + ); + } + }) + .on("error", reject); + }; + + download(fileUrl); + }); +} + +function extractTarGz(filePath, outputDir) { + return new Promise((resolve, reject) => { + fs.createReadStream(filePath) + .pipe(zlib.createGunzip()) + .pipe(tar.extract({ cwd: outputDir })) + .on("error", reject) + .on("finish", resolve); + }); +} + +async function ensureBinary() { + if (fs.existsSync(binaryPath)) { + return; + } + + const platformSpecificDownloadUrl = getPlatformSpecificDownloadUrl( + process.platform, + process.arch, + ); + + if (!fs.existsSync(downloadPath)) { + fs.mkdirSync(downloadPath, { recursive: true }); + } + + let tarGzPath; + try { + tarGzPath = await downloadFile(platformSpecificDownloadUrl, downloadPath); + } catch (e) { + console.error(e); + throw new Error(`Failed to download ${platformSpecificDownloadUrl}`); + } + try { + await extractTarGz(tarGzPath, downloadPath); + } catch (e) { + console.error(e); + throw new Error(`Failed to extract ${tarGzPath} to ${downloadPath}`); + } + + // Check if the binary exists + if (!fs.existsSync(path.join(downloadPath, binaryName))) { + throw new Error( + `Binary not found at ${downloadPath}. There might be a problem with the release files.`, + ); + } + + // make the binary executable + fs.chmodSync(path.join(downloadPath, binaryName), 0o755); +} + +async function main() { + try { + await ensureBinary(); + // run the binary + const args = process.argv.slice(2); + const child = spawn(binaryPath, args, { + stdio: "inherit", + }); + child.on("exit", (code) => { + process.exit(code); + }); + } catch (e) { + console.error(e); + process.exit(1); + } +} + +main(); diff --git a/mcp-package/package.json b/mcp-package/package.json new file mode 100644 index 00000000..03efee77 --- /dev/null +++ b/mcp-package/package.json @@ -0,0 +1,42 @@ +{ + "name": "@grafana/plugin-validator-mcp", + "version": "0.1.0", + "description": "Model Context Protocol (MCP) server for Grafana Plugin Validator - provides AI assistants with validation capabilities", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/grafana/plugin-validator.git" + }, + "keywords": [ + "Grafana", + "plugins", + "validator", + "MCP", + "Model Context Protocol", + "AI", + "assistant" + ], + "author": "Grafana", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/grafana/plugin-validator/issues" + }, + "homepage": "https://github.com/grafana/plugin-validator/tree/main/mcp-package#readme", + "files": [ + "index.js", + "README.md" + ], + "bin": { + "plugin-validator-mcp": "./index.js" + }, + "binWrapper": { + "name": "plugin-validator-mcp", + "urlTemplate": "https://github.com/grafana/plugin-validator/releases/download/mcp/v{{version}}/plugin-validator-mcp_{{version}}_{{platform}}_{{arch}}.tar.gz" + }, + "dependencies": { + "tar": "^7.4.3" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/scripts/install-mcp.sh b/scripts/install-mcp.sh deleted file mode 100755 index d79929ee..00000000 --- a/scripts/install-mcp.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo "Installing Grafana Plugin Validator MCP Server..." - -# Detect OS and architecture -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) - -case $ARCH in - x86_64) ARCH="amd64" ;; - aarch64|arm64) ARCH="arm64" ;; - i386|i686) ARCH="386" ;; - *) - echo -e "${RED}Error: Unsupported architecture: $ARCH${NC}" - exit 1 - ;; -esac - -if [[ "$OS" != "linux" && "$OS" != "darwin" ]]; then - echo -e "${RED}Error: Unsupported OS: $OS${NC}" - echo "This script is for Linux and macOS. For Windows, see the README.md" - exit 1 -fi - -# Get latest release version -echo "Fetching latest release..." -VERSION=$(curl -s https://api.github.com/repos/grafana/plugin-validator/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4) - -if [ -z "$VERSION" ]; then - echo -e "${RED}Error: Could not fetch latest release version${NC}" - exit 1 -fi - -echo -e "${GREEN}Latest version: $VERSION${NC}" - -# Download release -DOWNLOAD_URL="https://github.com/grafana/plugin-validator/releases/download/${VERSION}/plugin-validator_${VERSION#v}_${OS}_${ARCH}.tar.gz" -echo "Downloading from: $DOWNLOAD_URL" - -if ! curl -fL "$DOWNLOAD_URL" -o /tmp/plugin-validator.tar.gz; then - echo -e "${RED}Error: Failed to download release${NC}" - exit 1 -fi - -# Extract MCP server binary -echo "Extracting plugin-validator-mcp binary..." -if ! tar -xzf /tmp/plugin-validator.tar.gz -C /tmp plugin-validator-mcp 2>/dev/null; then - echo -e "${RED}Error: Failed to extract binary. The MCP server might not be included in this release.${NC}" - echo -e "${YELLOW}Please ensure you're using version v0.38.0 or later, or build from source.${NC}" - rm -f /tmp/plugin-validator.tar.gz - exit 1 -fi - -# Install to ~/.local/bin -INSTALL_DIR="${HOME}/.local/bin" -mkdir -p "$INSTALL_DIR" -mv /tmp/plugin-validator-mcp "$INSTALL_DIR/" -chmod +x "$INSTALL_DIR/plugin-validator-mcp" -rm /tmp/plugin-validator.tar.gz - -echo -e "${GREEN}✓ Installed to $INSTALL_DIR/plugin-validator-mcp${NC}" - -# Check if ~/.local/bin is in PATH -if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then - echo -e "${YELLOW}Warning: $INSTALL_DIR is not in your PATH${NC}" - echo "Add the following to your ~/.bashrc or ~/.zshrc:" - echo "" - echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" - echo "" -fi - -echo -e "${GREEN}Installation complete!${NC}" -echo "" -echo "Next steps:" -echo " 1. Configure the MCP server in your AI assistant (see README.md)" -echo " 2. Test the installation: plugin-validator-mcp" -echo "" -echo "For configuration examples, visit:" -echo " https://github.com/grafana/plugin-validator/blob/main/pkg/cmd/mcpserver/README.md" From fb0f3cf8fc800f0ae656ff9d0762619b7a90c2c6 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Thu, 19 Feb 2026 16:38:02 -0500 Subject: [PATCH 17/22] docs: codex config --- mcp-package/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/mcp-package/README.md b/mcp-package/README.md index 89c33222..b67ead3e 100644 --- a/mcp-package/README.md +++ b/mcp-package/README.md @@ -71,6 +71,30 @@ Add this to your Cline MCP settings: } ``` +### Codex + +Edit `~/.codex/config.toml` (or create `.codex/config.toml` in your project root for project-scoped configuration): + +**Using NPM (Recommended):** + +```toml +[mcp_servers.grafana-plugin-validator] +command = "npx" +args = ["-y", "@grafana/plugin-validator-mcp@latest"] +``` + +**Using Docker:** + +```toml +[mcp_servers.grafana-plugin-validator] +command = "docker" +args = [ + "run", "-i", "--rm", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "grafana/plugin-validator-mcp:latest" +] +``` + ### Other MCP Clients For other MCP-compatible AI assistants and editors, use: From 12883004e0033771990d966e55ff3f4e52a472b8 Mon Sep 17 00:00:00 2001 From: Syerikjan Kh Date: Fri, 20 Feb 2026 09:34:35 -0500 Subject: [PATCH 18/22] Update .goreleaser.mcp.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .goreleaser.mcp.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.goreleaser.mcp.yaml b/.goreleaser.mcp.yaml index 9c1aabea..4f9ba6a6 100644 --- a/.goreleaser.mcp.yaml +++ b/.goreleaser.mcp.yaml @@ -29,9 +29,6 @@ builds: archives: - name_template: "plugin-validator-mcp_{{ .Env.MCP_VERSION }}_{{ .Os }}_{{ .Arch }}" formats: [tar.gz] - format_overrides: - - goos: windows - formats: [zip] checksum: name_template: "checksums.txt" From 5ba0fcfce13ecc1854bcff216a59e32480662c18 Mon Sep 17 00:00:00 2001 From: Syerikjan Kh Date: Fri, 20 Feb 2026 09:36:18 -0500 Subject: [PATCH 19/22] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mcp-package/index.js | 8 +++++--- pkg/cmd/mcpserver/main.go | 8 +++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mcp-package/index.js b/mcp-package/index.js index 1b66c691..5aa7a785 100644 --- a/mcp-package/index.js +++ b/mcp-package/index.js @@ -131,8 +131,10 @@ async function ensureBinary() { ); } - // make the binary executable - fs.chmodSync(path.join(downloadPath, binaryName), 0o755); + // make the binary executable on Unix-like systems + if (process.platform !== "win32") { + fs.chmodSync(path.join(downloadPath, binaryName), 0o755); + } } async function main() { @@ -144,7 +146,7 @@ async function main() { stdio: "inherit", }); child.on("exit", (code) => { - process.exit(code); + process.exit(code ?? 1); }); } catch (e) { console.error(e); diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go index e545e046..6e6bd44a 100644 --- a/pkg/cmd/mcpserver/main.go +++ b/pkg/cmd/mcpserver/main.go @@ -62,10 +62,16 @@ func isNpxAvailable() bool { } func isLocalFilePath(path string) bool { + // Detect Windows drive letter paths like "C:\..." or "D:/..." + isWindowsDrivePath := len(path) >= 2 && + path[1] == ':' && + ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) + return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") || strings.HasPrefix(path, "file://") || + isWindowsDrivePath || (!strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://")) } @@ -90,7 +96,7 @@ func ValidatePlugin(ctx context.Context, req *mcp.CallToolRequest, input Input) var cmd *exec.Cmd var pluginArg string - // Docker is preferred then npx as fallback + // Docker is preferred, with npx as a fallback if useDocker { args := []string{"run", "--pull=always", "--rm"} From 988811727fc0d7b9d5dbeb9ff69d144c636b6a64 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 10:56:13 -0500 Subject: [PATCH 20/22] chore: readme.md --- pkg/cmd/mcpserver/README.md | 172 ++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 pkg/cmd/mcpserver/README.md diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md new file mode 100644 index 00000000..ba18a252 --- /dev/null +++ b/pkg/cmd/mcpserver/README.md @@ -0,0 +1,172 @@ +# Plugin Validator MCP Server + +An MCP (Model Context Protocol) server that provides Grafana plugin validation capabilities to AI assistants and code editors. + +## Building + +### Using Go + +```bash +# From the project root +go build -o bin/mcpserver ./pkg/cmd/mcpserver + +# Or using mage +mage build:commands +``` + +### Using Docker + +Build the binary using Docker (no Go installation required): + +```bash +# Build the Docker image (builds all binaries including mcpserver) +docker build -t plugin-validator-build . + +# Extract the mcpserver binary from the image +docker create --name temp-container plugin-validator-build +docker cp temp-container:/app/bin/linux_amd64/mcpserver ./mcpserver +docker rm temp-container + +# Move to installation directory +mkdir -p ~/.local/bin +mv ./mcpserver ~/.local/bin/plugin-validator-mcp +chmod +x ~/.local/bin/plugin-validator-mcp +``` + +## Installation + +### Quick Install (Linux/macOS) + +```bash +# Build and install to local bin +go build -o ~/.local/bin/plugin-validator-mcp ./pkg/cmd/mcpserver + +# Make sure ~/.local/bin is in your PATH +export PATH="$HOME/.local/bin:$PATH" +``` + +## Configuration + +### Claude Desktop (macOS) + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/Users/YOUR_USERNAME/.local/bin/plugin-validator-mcp" + } + } +} +``` + +### Claude Desktop (Linux) + +Edit `~/.config/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp" + } + } +} +``` + +### VS Code with Continue Extension + +Edit `~/.continue/config.json` (Linux/macOS): + +```json +{ + "mcpServers": [ + { + "name": "plugin-validator", + "command": "~/.local/bin/plugin-validator-mcp" + } + ] +} +``` + +### Cline (VS Code Extension) + +Edit `~/.cline/mcp_settings.json` (Linux/macOS): + +```json +{ + "mcpServers": { + "plugin-validator": { + "command": "/home/YOUR_USERNAME/.local/bin/plugin-validator-mcp", + "args": [] + } + } +} +``` + +## Usage + +Once configured, you can ask your AI assistant to validate Grafana plugins: + +``` +Validate this Grafana plugin: /path/to/plugin.zip +``` + +``` +Check this plugin with source code: +- Plugin: ./my-plugin.zip +- Source: https://github.com/user/my-plugin +``` + +## Tool Details + +### validate_plugin + +Validates a Grafana plugin against publishing requirements. + +**Inputs:** + +- `pluginPath` (required): Path or URL to the plugin archive (.zip) +- `sourceCodeUri` (optional): Path or URL to plugin source code (zip, folder, or git repo) + +**Output:** + +- `diagnostics`: Structured validation results with errors, warnings, and recommendations + +## Troubleshooting + +### Server not found + +Make sure the binary path is correct: + +```bash +which plugin-validator-mcp +# or +ls -la ~/.local/bin/plugin-validator-mcp +``` + +### Permission denied + +Make the binary executable: + +```bash +chmod +x ~/.local/bin/plugin-validator-mcp +``` + +### Test manually + +Run the server directly to check for errors: + +```bash +~/.local/bin/plugin-validator-mcp +# Press Ctrl+C to exit +``` + +## Development + +Run tests: + +```bash +go test ./pkg/cmd/mcpserver -v +``` From 16ce2d6e984ee07a86862c4c7b0c8fcd53cab3a5 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Tue, 17 Feb 2026 11:27:08 -0500 Subject: [PATCH 21/22] chore: regenerated readme --- pkg/cmd/mcpserver/README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md index ba18a252..ee60b122 100644 --- a/pkg/cmd/mcpserver/README.md +++ b/pkg/cmd/mcpserver/README.md @@ -4,8 +4,6 @@ An MCP (Model Context Protocol) server that provides Grafana plugin validation c ## Building -### Using Go - ```bash # From the project root go build -o bin/mcpserver ./pkg/cmd/mcpserver @@ -14,25 +12,6 @@ go build -o bin/mcpserver ./pkg/cmd/mcpserver mage build:commands ``` -### Using Docker - -Build the binary using Docker (no Go installation required): - -```bash -# Build the Docker image (builds all binaries including mcpserver) -docker build -t plugin-validator-build . - -# Extract the mcpserver binary from the image -docker create --name temp-container plugin-validator-build -docker cp temp-container:/app/bin/linux_amd64/mcpserver ./mcpserver -docker rm temp-container - -# Move to installation directory -mkdir -p ~/.local/bin -mv ./mcpserver ~/.local/bin/plugin-validator-mcp -chmod +x ~/.local/bin/plugin-validator-mcp -``` - ## Installation ### Quick Install (Linux/macOS) From 947a09d5c2fbd951527fc58e3902a9c70f1bd573 Mon Sep 17 00:00:00 2001 From: "Syerikjan(Sam)" Date: Fri, 20 Feb 2026 10:46:29 -0500 Subject: [PATCH 22/22] test: test mcpserver where docker is available --- .github/workflows/test.yml | 16 ++++++ README.md | 106 ++++++++++++++++++------------------- 2 files changed, 68 insertions(+), 54 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 555a91c0..a2c84c64 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,22 @@ on: - renovate/* jobs: + test-mcp-server: + runs-on: ubuntu-x64 + permissions: + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run MCP server tests + run: go test -v ./pkg/cmd/mcpserver/... + test-docker-build: runs-on: ubuntu-x64 permissions: diff --git a/README.md b/README.md index 49315667..6075fc65 100644 --- a/README.md +++ b/README.md @@ -253,60 +253,58 @@ The tool runs a series of analyzers to ensure submitted plugins are following be THE FOLLOWING SECTION IS GENERATED, DO NOT EDIT. Run "mage gen:readme" to regenerate this section. --> - -| Analyzer | Description | Dependencies | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | -| Archive Name / `archivename` | The name of the archive should be correctly formatted. | None | -| Archive Structure / `archive` | Ensures the contents of the zip file have the expected layout. | None | -| Backend Binary / `backendbinary` | Validates the consistency between the existence of a binary file and plugin.json declarations for backend or alerting. | None | -| Backend Debug / `backenddebug` | Checks that the standalone debug files for backend plugins are not present. | None | -| Binary Permissions / `binarypermissions` | For datasources and apps with binaries, this ensures the plugin can run when extracted on a system. | None | -| Broken Links / `brokenlinks` | Detects if any URL doesn't resolve to a valid location. | None | -| Build Tools / `buildtools` | Checks that the plugin uses Grafana's standard create-plugin build tooling. | None | -| Changelog (exists) / `changelog` | Ensures a `CHANGELOG.md` file exists within the zip file. | None | -| Checksum / `checksum` | Validates that the passed checksum (as a validator arg) is the one calculated from the archive file. | `checksum` | -| Circular Dependencies / `circulardependencies` | Ensures that there aren't any circular dependencies between plugins (`plugin.json`, `dependencies.plugins` field). | None | -| Code Diff / `codediff` | | Google API Key with Generative AI access | -| Code Rules / `code-rules` | Checks for forbidden access to environment variables, file system or use of syscall module. | [semgrep](https://github.com/returntocorp/semgrep), `sourceCodeUri` | -| Developer Jargon / `jargon` | Generally discourages use of code jargon in the documentation. | None | -| Discoverability / `discoverability` | Warns about missing keywords and description that are used for plugin indexing in the catalog. | None | -| Go Manifest / `go-manifest` | Validates the build manifest. | None | -| Go Security Checker / `go-sec` | Inspects source code for security problems by scanning the Go AST. | [gosec](https://github.com/securego/gosec), `sourceCodeUri` | -| JS Source Map / `jsMap` | Checks for required `module.js.map` file(s) in archive. | `sourceCodeUri` | -| Legacy Grafana Toolkit usage / `legacybuilder` | Detects the usage of the not longer supported Grafana Toolkit. | None | -| Legacy Platform / `legacyplatform` | Detects use of Angular which is deprecated. | None | -| License Type / `license` | Checks the declared license is one of: BSD, MIT, Apache 2.0, LGPL3, GPL3, AGPL3. | None | -| LLM Review / `llmreview` | Runs the code through Gemini LLM to check for security issues or disallowed usage. | Gemini API key | -| Logos / `logos` | Detects whether the plugin includes small and large logos to display in the plugin catalog. | None | -| Manifest (Signing) / `manifest` | When a plugin is signed, the zip file will contain a signed `MANIFEST.txt` file. | None | -| Metadata / `metadata` | Checks that `plugin.json` exists and is valid. | None | -| Metadata Grafana Dependency / `grafanadependency` | Checks that dependencies.grafanaDependency in `plugin.json` is valid. | None | -| Metadata Paths / `metadatapaths` | Ensures all paths are valid and images referenced exist. | None | -| Metadata Validity / `metadatavalid` | Ensures metadata is valid and matches plugin schema. | None | -| module.js (exists) / `modulejs` | All plugins require a `module.js` to be loaded. | None | -| Nested includes metadata / `includesnested` | Validates that nested plugins have the correct metadata. | None | -| Nested Metadata / `nestedmetadata` | Recursively checks that all `plugin.json` exist and are valid. | None | -| No Tracking Scripts / `trackingscripts` | Detects if there are any known tracking scripts, which are not allowed. | None | -| Organization (exists) / `org` | Verifies the org specified in the plugin ID exists. | None | -| package.json / `packagejson` | Ensures that package.json exists and the version matches the plugin.json | None | -| Plugin Name formatting / `pluginname` | Validates the plugin ID used conforms to our naming convention. | None | -| Provenance attestation validation / `provenance` | Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions). | None | -| Published / `published-plugin` | Detects whether any version of this plugin exists in the Grafana plugin catalog currently. | None | -| Readme (exists) / `readme` | Ensures a `README.md` file exists within the zip file. | None | -| Restrictive Dependency / `restrictivedep` | Specifies a valid range of Grafana versions that work with this version of the plugin. | None | -| Safe Links / `safelinks` | Checks that links from `plugin.json` are safe. | None | -| Screenshots / `screenshots` | Screenshots are specified in `plugin.json` that will be used in the Grafana plugin catalog. | None | -| SDK Usage / `sdkusage` | Ensures that `grafana-plugin-sdk-go` is up-to-date. | None | -| Signature / `signature` | Ensures the plugin has a valid signature. | None | -| Source Code / `sourcecode` | A comparison is made between the zip file and the source code to ensure what is released matches the repo associated with it. | `sourceCodeUri` | -| Sponsorship Link / `sponsorshiplink` | Checks if a sponsorship link is specified in `plugin.json` that will be shown in the Grafana plugin catalog for users to support the plugin developer. | None | -| Type Suffix (panel/app/datasource) / `typesuffix` | Ensures the plugin has a valid type specified. | None | -| Unique README.md / `templatereadme` | Ensures the plugin doesn't re-use the template from the `create-plugin` tool. | None | -| Unsafe SVG / `unsafesvg` | Checks if any svg files are safe based on a whitelist of elements and attributes. | None | -| Version / `version` | Ensures the version submitted is newer than the currently published plugin. If this is a new/unpublished plugin, this is skipped. | None | -| Virus Scan / `virusscan` | Runs a virus scan on the plugin archive and source code using `clamscan` (`clamav`). | clamscan | -| Vulnerability Scanner / `osv-scanner` | Detects critical vulnerabilities in Go modules and yarn lock files. | [osv-scanner](https://github.com/google/osv-scanner), `sourceCodeUri` | - +| Analyzer | Description | Dependencies | +|----------|-------------|--------------| +| Archive Name / `archivename` | The name of the archive should be correctly formatted. | None | +| Archive Structure / `archive` | Ensures the contents of the zip file have the expected layout. | None | +| Backend Binary / `backendbinary` | Validates the consistency between the existence of a binary file and plugin.json declarations for backend or alerting. | None | +| Backend Debug / `backenddebug` | Checks that the standalone debug files for backend plugins are not present. | None | +| Binary Permissions / `binarypermissions` | For datasources and apps with binaries, this ensures the plugin can run when extracted on a system. | None | +| Broken Links / `brokenlinks` | Detects if any URL doesn't resolve to a valid location. | None | +| Build Tools / `buildtools` | Checks that the plugin uses Grafana's standard create-plugin build tooling. | None | +| Changelog (exists) / `changelog` | Ensures a `CHANGELOG.md` file exists within the zip file. | None | +| Checksum / `checksum` | Validates that the passed checksum (as a validator arg) is the one calculated from the archive file. | `checksum` | +| Circular Dependencies / `circulardependencies` | Ensures that there aren't any circular dependencies between plugins (`plugin.json`, `dependencies.plugins` field). | None | +| Code Diff / `codediff` | | Google API Key with Generative AI access | +| Code Rules / `code-rules` | Checks for forbidden access to environment variables, file system or use of syscall module. | [semgrep](https://github.com/returntocorp/semgrep), `sourceCodeUri` | +| Developer Jargon / `jargon` | Generally discourages use of code jargon in the documentation. | None | +| Discoverability / `discoverability` | Warns about missing keywords and description that are used for plugin indexing in the catalog. | None | +| Go Manifest / `go-manifest` | Validates the build manifest. | None | +| Go Security Checker / `go-sec` | Inspects source code for security problems by scanning the Go AST. | [gosec](https://github.com/securego/gosec), `sourceCodeUri` | +| JS Source Map / `jsMap` | Checks for required `module.js.map` file(s) in archive. | `sourceCodeUri` | +| Legacy Grafana Toolkit usage / `legacybuilder` | Detects the usage of the not longer supported Grafana Toolkit. | None | +| Legacy Platform / `legacyplatform` | Detects use of Angular which is deprecated. | None | +| License Type / `license` | Checks the declared license is one of: BSD, MIT, Apache 2.0, LGPL3, GPL3, AGPL3. | None | +| LLM Review / `llmreview` | Runs the code through Gemini LLM to check for security issues or disallowed usage. | Gemini API key | +| Logos / `logos` | Detects whether the plugin includes small and large logos to display in the plugin catalog. | None | +| Manifest (Signing) / `manifest` | When a plugin is signed, the zip file will contain a signed `MANIFEST.txt` file. | None | +| Metadata / `metadata` | Checks that `plugin.json` exists and is valid. | None | +| Metadata Grafana Dependency / `grafanadependency` | Checks that dependencies.grafanaDependency in `plugin.json` is valid. | None | +| Metadata Paths / `metadatapaths` | Ensures all paths are valid and images referenced exist. | None | +| Metadata Validity / `metadatavalid` | Ensures metadata is valid and matches plugin schema. | None | +| module.js (exists) / `modulejs` | All plugins require a `module.js` to be loaded. | None | +| Nested includes metadata / `includesnested` | Validates that nested plugins have the correct metadata. | None | +| Nested Metadata / `nestedmetadata` | Recursively checks that all `plugin.json` exist and are valid. | None | +| No Tracking Scripts / `trackingscripts` | Detects if there are any known tracking scripts, which are not allowed. | None | +| Organization (exists) / `org` | Verifies the org specified in the plugin ID exists. | None | +| package.json / `packagejson` | Ensures that package.json exists and the version matches the plugin.json | None | +| Plugin Name formatting / `pluginname` | Validates the plugin ID used conforms to our naming convention. | None | +| Provenance attestation validation / `provenance` | Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions). | None | +| Published / `published-plugin` | Detects whether any version of this plugin exists in the Grafana plugin catalog currently. | None | +| Readme (exists) / `readme` | Ensures a `README.md` file exists within the zip file. | None | +| Restrictive Dependency / `restrictivedep` | Specifies a valid range of Grafana versions that work with this version of the plugin. | None | +| Safe Links / `safelinks` | Checks that links from `plugin.json` are safe. | None | +| Screenshots / `screenshots` | Screenshots are specified in `plugin.json` that will be used in the Grafana plugin catalog. | None | +| SDK Usage / `sdkusage` | Ensures that `grafana-plugin-sdk-go` is up-to-date. | None | +| Signature / `signature` | Ensures the plugin has a valid signature. | None | +| Source Code / `sourcecode` | A comparison is made between the zip file and the source code to ensure what is released matches the repo associated with it. | `sourceCodeUri` | +| Sponsorship Link / `sponsorshiplink` | Checks if a sponsorship link is specified in `plugin.json` that will be shown in the Grafana plugin catalog for users to support the plugin developer. | None | +| Type Suffix (panel/app/datasource) / `typesuffix` | Ensures the plugin has a valid type specified. | None | +| Unique README.md / `templatereadme` | Ensures the plugin doesn't re-use the template from the `create-plugin` tool. | None | +| Unsafe SVG / `unsafesvg` | Checks if any svg files are safe based on a whitelist of elements and attributes. | None | +| Version / `version` | Ensures the version submitted is newer than the currently published plugin. If this is a new/unpublished plugin, this is skipped. | None | +| Virus Scan / `virusscan` | Runs a virus scan on the plugin archive and source code using `clamscan` (`clamav`). | clamscan | +| Vulnerability Scanner / `osv-scanner` | Detects critical vulnerabilities in Go modules and yarn lock files. | [osv-scanner](https://github.com/google/osv-scanner), `sourceCodeUri` | ## Output