Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions .claude-plugin/install-binary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
#!/usr/bin/env node

import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import { pipeline } from 'node:stream/promises';

const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT;
if (!PLUGIN_ROOT) {
console.error('Error: CLAUDE_PLUGIN_ROOT environment variable not set');
process.exit(1);
}

// Detect OS and architecture
const platform = process.platform;
const arch = process.arch;

let OS, ARCH, EXT, BINARY_NAME, BINARY_PATH;

// Map Node.js arch to release naming
switch (arch) {
case 'x64':
ARCH = 'x86_64';
break;
case 'arm64':
ARCH = 'arm64';
break;
case 'ia32':
ARCH = 'i386';
break;
default:
console.error(`Unsupported architecture: ${arch}`);
process.exit(1);
}

// Map Node.js platform to release naming
switch (platform) {
case 'darwin':
OS = 'Darwin';
EXT = 'tar.gz';
BINARY_NAME = 'mcp-grafana';
break;
case 'linux':
OS = 'Linux';
EXT = 'tar.gz';
BINARY_NAME = 'mcp-grafana';
break;
case 'win32':
OS = 'Windows';
EXT = 'zip';
BINARY_NAME = 'mcp-grafana.exe';
break;
default:
console.error(`Unsupported OS: ${platform}`);
process.exit(1);
}

BINARY_PATH = join(PLUGIN_ROOT, BINARY_NAME);

// Fetch latest version from GitHub API
async function getLatestVersion() {
const headers = {};
// Use GitHub token if available (for CI environments)
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
}

const response = await fetch('https://api.github.com/repos/grafana/mcp-grafana/releases/latest', {
headers
});
if (!response.ok) {
throw new Error(`Failed to fetch latest version: ${response.statusText}`);
}
const data = await response.json();
return data.tag_name;
}

// Download file from URL
async function downloadFile(url, destPath) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.statusText}`);
}
await pipeline(response.body, createWriteStream(destPath));
}

// Verify SHA256 checksum
async function verifyChecksum(filePath, checksumsContent, archiveName) {
const fileBuffer = readFileSync(filePath);
const hash = createHash('sha256').update(fileBuffer).digest('hex');

const lines = checksumsContent.split('\n');
for (const line of lines) {
if (line.includes(archiveName)) {
const [expectedHash] = line.split(/\s+/);
if (hash === expectedHash) {
console.error(`✓ Checksum verified`);
return true;
} else {
throw new Error(`Checksum mismatch for ${archiveName}`);
}
}
}
throw new Error(`No checksum found for ${archiveName}`);
}

// Extract tar.gz archive using system tar command
function extractTarGz(archivePath, destDir) {
return new Promise((resolve, reject) => {
const tar = spawn('tar', ['-xzf', archivePath, '-C', destDir]);
tar.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`tar extraction failed with code ${code}`));
});
});
}

// Extract zip archive using system command
function extractZip(archivePath, destDir) {
return new Promise((resolve, reject) => {
// Use PowerShell on Windows
const unzip = spawn('powershell', ['-Command', `Expand-Archive -Path "${archivePath}" -DestinationPath "${destDir}" -Force`]);
unzip.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`zip extraction failed with code ${code}`));
});
});
}

async function main() {
try {
// Get latest version
console.error('Fetching latest version...');
const VERSION = await getLatestVersion();

const ARCHIVE_NAME = `mcp-grafana_${OS}_${ARCH}.${EXT}`;
const VERSION_FILE = join(PLUGIN_ROOT, '.mcp-grafana-version');

// Check if binary exists and version matches
const needsInstall = !existsSync(BINARY_PATH) ||
!existsSync(VERSION_FILE) ||
readFileSync(VERSION_FILE, 'utf8').trim() !== VERSION;

if (!needsInstall) {
// Binary is up to date, just execute it
const child = spawn(BINARY_PATH, process.argv.slice(2), { stdio: 'inherit' });
child.on('exit', (code) => process.exit(code || 0));
return;
}

console.error(`Downloading mcp-grafana ${VERSION} for ${OS}-${ARCH}...`);

// Create temp directory
const TEMP_DIR = join(tmpdir(), `mcp-grafana-${Date.now()}`);
mkdirSync(TEMP_DIR, { recursive: true });

try {
const ARCHIVE_PATH = join(TEMP_DIR, ARCHIVE_NAME);
const DOWNLOAD_URL = `https://github.com/grafana/mcp-grafana/releases/latest/download/${ARCHIVE_NAME}`;

// Download archive
await downloadFile(DOWNLOAD_URL, ARCHIVE_PATH);

// Download and verify checksums
console.error('Verifying checksum...');
const VERSION_NUMBER = VERSION.replace(/^v/, ''); // Remove 'v' prefix
const CHECKSUMS_URL = `https://github.com/grafana/mcp-grafana/releases/download/${VERSION}/mcp-grafana_${VERSION_NUMBER}_checksums.txt`;
const checksumResponse = await fetch(CHECKSUMS_URL);
if (!checksumResponse.ok) {
throw new Error(`Failed to download checksums: ${checksumResponse.statusText}`);
}
const checksumsContent = await checksumResponse.text();
await verifyChecksum(ARCHIVE_PATH, checksumsContent, ARCHIVE_NAME);

// Extract archive
console.error('Extracting archive...');
if (EXT === 'tar.gz') {
await extractTarGz(ARCHIVE_PATH, TEMP_DIR);
} else {
await extractZip(ARCHIVE_PATH, TEMP_DIR);
}

// Move binary to plugin root
const extractedBinary = join(TEMP_DIR, BINARY_NAME);
if (!existsSync(extractedBinary)) {
throw new Error(`Binary not found after extraction: ${extractedBinary}`);
}

mkdirSync(PLUGIN_ROOT, { recursive: true });
const binaryContent = readFileSync(extractedBinary);
writeFileSync(BINARY_PATH, binaryContent);

if (platform !== 'win32') {
chmodSync(BINARY_PATH, 0o755);
}

writeFileSync(VERSION_FILE, VERSION);

console.error(`Successfully installed mcp-grafana ${VERSION}`);
} finally {
// Cleanup temp directory
try {
const { rmSync } = await import('fs');
rmSync(TEMP_DIR, { recursive: true, force: true });
} catch (e) {
// Ignore cleanup errors
}
}

// Execute the binary
const child = spawn(BINARY_PATH, process.argv.slice(2), { stdio: 'inherit' });
child.on('exit', (code) => process.exit(code || 0));

} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}

main();
17 changes: 17 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "grafana",
"version": "0.7.6",
"description": "A Model Context Protocol (MCP) server for Grafana providing access to dashboards, datasources, and querying capabilities",
"author": {
"name": "Grafana Labs"
},
"homepage": "https://github.com/grafana/mcp-grafana",
"repository": "https://github.com/grafana/mcp-grafana",
"license": "Apache-2.0",
"mcpServers": {
"grafana": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/.claude-plugin/install-binary.mjs"]
}
}
}
155 changes: 155 additions & 0 deletions .github/workflows/test-install-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: Test Install Scripts

on:
pull_request:
paths:
- '.claude-plugin/**'
- '.github/workflows/test-install-scripts.yml'
push:
branches:
- main
paths:
- '.claude-plugin/**'
- '.github/workflows/test-install-scripts.yml'
workflow_dispatch:

jobs:
test-nodejs-script:
name: Test Node.js Script
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ['20', '22', '24']
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

- name: Test Node.js install script (Unix)
if: runner.os != 'Windows'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TEMP_DIR=$(mktemp -d)
export CLAUDE_PLUGIN_ROOT="${TEMP_DIR}"

echo "Testing Node.js install script..."
node .claude-plugin/install-binary.mjs --version

VERSION=$("${TEMP_DIR}/mcp-grafana" --version)
echo "Installed version: ${VERSION}"

if [ -z "${VERSION}" ]; then
echo "Error: Failed to get version"
exit 1
fi

echo "✓ Node.js script test passed"
rm -rf "${TEMP_DIR}"

- name: Test Node.js install script (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$TempDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([System.IO.Path]::GetRandomFileName()))
$env:CLAUDE_PLUGIN_ROOT = $TempDir.FullName

Write-Host "Testing Node.js install script..."
node .claude-plugin/install-binary.mjs --version

$BinaryPath = Join-Path $TempDir.FullName "mcp-grafana.exe"
$Version = & $BinaryPath --version
Write-Host "Installed version: $Version"

if ([string]::IsNullOrEmpty($Version)) {
Write-Error "Failed to get version"
exit 1
}

Write-Host "✓ Node.js script test passed"
Remove-Item -Recurse -Force $TempDir

test-checksum-verification:
name: Test Checksum Verification
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Test checksum verification with Node.js
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TEMP_DIR=$(mktemp -d)
export CLAUDE_PLUGIN_ROOT="${TEMP_DIR}"

echo "Testing checksum verification..."
OUTPUT=$(node .claude-plugin/install-binary.mjs --version 2>&1)
echo "Script output:"
echo "$OUTPUT"

if echo "$OUTPUT" | grep -q "Checksum verified"; then
echo "✓ Checksum verification executed"
else
echo "Error: Checksum verification not executed"
exit 1
fi

rm -rf "${TEMP_DIR}"

test-version-update:
name: Test Version Update Detection
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Test version update detection
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TEMP_DIR=$(mktemp -d)
export CLAUDE_PLUGIN_ROOT="${TEMP_DIR}"

echo "First installation..."
node .claude-plugin/install-binary.mjs --version

VERSION_FILE="${TEMP_DIR}/.mcp-grafana-version"
if [ ! -f "${VERSION_FILE}" ]; then
echo "Error: Version file not created"
exit 1
fi

STORED_VERSION=$(cat "${VERSION_FILE}")
echo "Stored version: ${STORED_VERSION}"

echo "Second run (should skip download)..."
OUTPUT=$(node .claude-plugin/install-binary.mjs --version 2>&1)

if echo "${OUTPUT}" | grep -q "Downloading"; then
echo "Error: Should not download on second run"
exit 1
fi

echo "✓ Version update detection works"
rm -rf "${TEMP_DIR}"
Loading