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/README.md b/README.md index 3d9713da..87260cca 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. diff --git a/go.mod b/go.mod index c74343bd..48935a56 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,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 @@ -122,6 +123,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 @@ -193,6 +195,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 ecfdbe41..1a228ad9 100644 --- a/go.sum +++ b/go.sum @@ -228,6 +228,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= @@ -260,6 +262,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= @@ -352,6 +356,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= @@ -508,6 +514,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/README.md b/pkg/cmd/mcpserver/README.md new file mode 100644 index 00000000..89b91acf --- /dev/null +++ b/pkg/cmd/mcpserver/README.md @@ -0,0 +1,236 @@ +# 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 new file mode 100644 index 00000000..20f72214 --- /dev/null +++ b/pkg/cmd/mcpserver/main.go @@ -0,0 +1,264 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "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" 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."` +} + +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."` +} + +type cliOutput struct { + ID string `json:"id"` + Version string `json:"version"` + PluginValidator 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) { + 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") + } + + 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...) + log.Printf("[MCP] Executing: docker %v", 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...) + log.Printf("[MCP] Executing: npx %v", 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 + 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{ + { + Name: "validation-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)), + }, + }, + } + return nil, Output{ + 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") + + 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 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) + } + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} diff --git a/pkg/cmd/mcpserver/main_test.go b/pkg/cmd/mcpserver/main_test.go new file mode 100644 index 00000000..9b01105d --- /dev/null +++ b/pkg/cmd/mcpserver/main_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +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, + 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) { + // 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, + 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)) +} 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 +} 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"