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/.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/.goreleaser.mcp.yaml b/.goreleaser.mcp.yaml new file mode 100644 index 00000000..4f9ba6a6 --- /dev/null +++ b/.goreleaser.mcp.yaml @@ -0,0 +1,37 @@ +# 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] + +checksum: + name_template: "checksums.txt" + +changelog: + disable: true diff --git a/README.md b/README.md index babf03b0..6075fc65 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ Then you can run the utility: plugincheck2 -sourceCodeUri [source_code_location/] [plugin_archive.zip] ``` +### MCP Server (for AI assistants) + +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. + +See the [MCP Server README](mcp-package/README.md) for configuration instructions. + ### Generating local files For validation You must create a `.zip` archive containing the `dist/` directory but named as your plugin ID: @@ -186,7 +192,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 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/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..b67ead3e --- /dev/null +++ b/mcp-package/README.md @@ -0,0 +1,183 @@ +# 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"] + } + } +} +``` + +### 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: + +```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..5aa7a785 --- /dev/null +++ b/mcp-package/index.js @@ -0,0 +1,157 @@ +#!/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 on Unix-like systems + if (process.platform !== "win32") { + 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 ?? 1); + }); + } 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/pkg/cmd/mcpserver/README.md b/pkg/cmd/mcpserver/README.md new file mode 100644 index 00000000..ee60b122 --- /dev/null +++ b/pkg/cmd/mcpserver/README.md @@ -0,0 +1,151 @@ +# 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 (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 +``` diff --git a/pkg/cmd/mcpserver/main.go b/pkg/cmd/mcpserver/main.go new file mode 100644 index 00000000..6e6bd44a --- /dev/null +++ b/pkg/cmd/mcpserver/main.go @@ -0,0 +1,222 @@ +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 + +var version = "dev" + +// 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 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."` + 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 { + 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 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://")) +} + +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 + + // Docker is preferred, with npx as a fallback + if useDocker { + args := []string{"run", "--pull=always", "--rm"} + + // Mount local files if needed + if isLocalFilePath(input.PluginPath) { + 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 != "" { + + if isLocalFilePath(input.SourceCodeUri) { + 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, + }, nil + } + + return nil, Output{ + PluginID: cliOut.ID, + Version: cliOut.Version, + Diagnostics: cliOut.PluginValidator, + }, nil +} + +func run() error { + log.SetOutput(os.Stderr) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.Printf("[MCP] Starting plugin-validator MCP server v%s", version) + + 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.", + }, 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)) +}