Playwright-Lighthouse MCP Server

by kbyk004
Verified
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { chromium, Browser, Page } from "playwright"; import { playAudit } from "playwright-lighthouse"; import { writeFileSync, existsSync, readFileSync, readdirSync, mkdirSync, statSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; // Get the current directory path const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Create directory for saving reports const reportsDir = path.join(__dirname, "../reports"); if (!existsSync(reportsDir)) { mkdirSync(reportsDir, { recursive: true }); } // Create MCP server const server = new McpServer({ name: "playwright-lighthouse", version: "1.0.0", }); // Variable to hold browser instance let browser: Browser | null = null; let page: Page | null = null; // Function to launch browser async function launchBrowser() { if (!browser) { // Launch browser with remote debugging port browser = await chromium.launch({ headless: true, args: [ '--remote-debugging-port=9222', '--ignore-certificate-errors' ], timeout: 30000, }); } return browser; } // Function to open a page async function getPage() { if (!page) { const browser = await launchBrowser(); page = await browser.newPage(); } return page; } // Function to navigate to URL async function navigateToUrl(url: string) { try { const page = await getPage(); await page.goto(url, { waitUntil: "load" }); return page; } catch (error) { throw error; } } // Function to close browser async function closeBrowser() { if (browser) { await browser.close(); browser = null; page = null; } } // Tool 1: Run Lighthouse performance analysis server.tool( "run-lighthouse", "Runs a Lighthouse performance analysis on the currently open page", { url: z.string().url().describe("URL of the website you want to analyze"), categories: z.array(z.enum(["performance", "accessibility", "best-practices", "seo", "pwa"])) .default(["performance"]) .describe("Categories to analyze (performance, accessibility, best-practices, seo, pwa)"), maxItems: z.number().min(1).max(5).default(3) .describe("Maximum number of improvement items to display for each category"), }, async (params, extra): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean; }> => { try { // Automatically launch browser and navigate to URL await navigateToUrl(params.url); const url = page!.url(); try { // CDP connection method for the latest Playwright version const browserContext = browser!.contexts()[0]; const cdpSession = await browserContext.newCDPSession(page!); // Get browser version information to check debug port const versionInfo = await cdpSession.send('Browser.getVersion'); // Get port number from WebSocket debugger URL // Note: Using the port specified at launch (9222) const port = 9222; // Function to run Lighthouse audit const runAudit = async () => { try { // Create report path const hostname = new URL(url).hostname.replace(/\./g, '-'); const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0]; const reportPath = path.join(__dirname, `../reports/lighthouse-${hostname}-${timestamp}.json`); try { // Run Lighthouse audit const results = await playAudit({ page: page!, port: port, thresholds: { performance: 0, accessibility: 0, 'best-practices': 0, seo: 0, pwa: 0 }, reports: { formats: { html: false, json: true }, directory: path.join(__dirname, "../reports"), name: `lighthouse-${hostname}-${timestamp}` }, ignoreError: true, config: { extends: 'lighthouse:default' } }); // Function to represent score evaluation with color const getScoreEmoji = (score: number): string => { if (score >= 90) return "🟢"; // Good if (score >= 50) return "🟠"; // Average return "🔴"; // Poor }; // Process results directly let scoreText = "📊 Lighthouse Scores:\n"; let improvementText = "\n\n🔍 Key Improvement Areas:"; // Prepare arrays to store improvement items const improvementItems: { category: string; title: string; description: string }[] = []; // Check if results are available directly if (results && results.lhr && results.lhr.categories) { // Get selected categories from the direct results const availableCategories = Object.keys(results.lhr.categories); // Filter categories based on user selection const selectedCategories = params.categories.filter(cat => availableCategories.includes(cat) ); // Process each category for (const category of selectedCategories) { const categoryData = results.lhr.categories[category]; if (categoryData) { // Get all audits for this category const audits = results.lhr.audits; const categoryAudits = Object.keys(audits).filter( auditId => { const audit = audits[auditId]; return audit.details && categoryData.auditRefs.some((ref: any) => ref.id === auditId); } ); // Get score let scoreDisplay = ''; if (categoryData.score === null) { // When score cannot be calculated scoreDisplay = `⚪️ ${category.charAt(0).toUpperCase() + category.slice(1)}: Not measurable`; } else { // When score can be calculated const score = Math.round(categoryData.score * 100); scoreDisplay = `${getScoreEmoji(score)} ${category.charAt(0).toUpperCase() + category.slice(1)}: ${score}/100`; } // Add score to response scoreText += scoreDisplay + '\n'; // Collect improvement items for (const auditId of categoryAudits) { const audit = audits[auditId]; if ((audit.score || 0) < 0.9) { improvementItems.push({ category, title: audit.title, description: audit.description, }); } } } } } else { // Fallback to reading from file if direct results are not available try { // Load JSON file if (existsSync(reportPath)) { // Read and parse JSON file const jsonData = JSON.parse(readFileSync(reportPath, 'utf8')); if (jsonData && jsonData.categories) { // Get selected categories from the report const availableCategories = Object.keys(jsonData.categories); // Filter categories based on user selection const selectedCategories = params.categories.filter(cat => availableCategories.includes(cat) ); // Process each category for (const category of selectedCategories) { const categoryData = jsonData.categories[category]; if (categoryData) { // Get all audits for this category const audits = jsonData.audits; const categoryAudits = Object.keys(audits).filter( auditId => { const audit = audits[auditId]; return audit.details && categoryData.auditRefs.some((ref: any) => ref.id === auditId); } ); // Get score let scoreDisplay = ''; if (categoryData.score === null) { // When score cannot be calculated scoreDisplay = `⚪️ ${category.charAt(0).toUpperCase() + category.slice(1)}: Not measurable`; } else { // When score can be calculated const score = Math.round(categoryData.score * 100); scoreDisplay = `${getScoreEmoji(score)} ${category.charAt(0).toUpperCase() + category.slice(1)}: ${score}/100`; } // Add score to response scoreText += scoreDisplay + '\n'; // Collect improvement items for (const auditId of categoryAudits) { const audit = audits[auditId]; if ((audit.score || 0) < 0.9) { improvementItems.push({ category, title: audit.title, description: audit.description, }); } } } } } } else { // List all files in directory const files = readdirSync(path.join(__dirname, "../reports")); // Find the latest JSON file const jsonFiles = files.filter(file => file.endsWith('.json')); if (jsonFiles.length > 0) { const latestFile = jsonFiles.sort().pop(); // Use the latest file const latestPath = path.join(__dirname, "../reports", latestFile || ''); try { const latestData = JSON.parse(readFileSync(latestPath, 'utf8')); if (latestData && latestData.categories) { // Process each category for (const category of params.categories) { const categoryData = latestData.categories[category]; if (categoryData) { // Get all audits for this category const audits = latestData.audits; const categoryAudits = Object.keys(audits).filter( auditId => { const audit = audits[auditId]; return audit.details && categoryData.auditRefs.some((ref: any) => ref.id === auditId); } ); // Get score let scoreDisplay = ''; if (categoryData.score === null) { // When score cannot be calculated scoreDisplay = `⚪️ ${category.charAt(0).toUpperCase() + category.slice(1)}: Not measurable`; } else { // When score can be calculated const score = Math.round(categoryData.score * 100); scoreDisplay = `${getScoreEmoji(score)} ${category.charAt(0).toUpperCase() + category.slice(1)}: ${score}/100`; } // Add score to response scoreText += scoreDisplay + '\n'; // Collect improvement items for (const auditId of categoryAudits) { const audit = audits[auditId]; if ((audit.score || 0) < 0.9) { improvementItems.push({ category, title: audit.title, description: audit.description, }); } } } } } } catch (err: any) { throw new Error(`Failed to read latest JSON file: ${err.message}`); } } else { throw new Error('Lighthouse report file not found.'); } } } catch (error) { throw error; // Propagate to higher error handler } } // Display improvement points (sorted by weight) if (improvementItems.length > 0) { // Sort by category improvementItems.sort((a, b) => { if (a.category !== b.category) { return a.category.localeCompare(b.category); } return a.title.localeCompare(b.title); }); // Group and display let currentCategory = ''; for (const imp of improvementItems.slice(0, params.maxItems * params.categories.length)) { if (currentCategory !== imp.category) { currentCategory = imp.category; // Display category name appropriately const categoryDisplayName = { 'performance': 'Performance', 'accessibility': 'Accessibility', 'best-practices': 'Best Practices', 'seo': 'SEO', 'pwa': 'PWA' }[imp.category] || imp.category; improvementText += `\n\n【${categoryDisplayName}】Improvement items:`; } improvementText += `\n・${imp.title}`; } } else { improvementText += "\n\nNo improvement items found."; } // Close browser automatically after analysis is complete await closeBrowser(); // Return the results return { content: [ { type: "text" as const, text: scoreText + improvementText, }, { type: "text" as const, text: `report save path: ${reportPath}`, }, ], }; } catch (error: any) { // Close browser even when an error occurs await closeBrowser(); throw error; // Propagate to higher error handler } } catch (error: any) { // Close browser even when an error occurs await closeBrowser(); return { content: [ { type: "text" as const, text: `An error occurred during Lighthouse analysis: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }; return await runAudit(); } catch (error: any) { // Close browser even when an error occurs await closeBrowser(); return { content: [ { type: "text" as const, text: `An error occurred during Lighthouse analysis: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } catch (error: any) { // Close browser even when an error occurs await closeBrowser(); return { content: [ { type: "text" as const, text: `An error occurred during Lighthouse analysis: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); // Tool 2: Take screenshot server.tool( "take-screenshot", "Takes a screenshot of the currently open page", { url: z.string().url().describe("URL of the website you want to capture"), fullPage: z.boolean().default(false).describe("If true, captures a screenshot of the entire page"), }, async ({ url, fullPage }) => { try { // Automatically launch browser and navigate to URL await navigateToUrl(url); const screenshot = await page!.screenshot({ fullPage, type: "jpeg", quality: 80 }); // Create directory for screenshots if it doesn't exist const screenshotsDir = path.join(__dirname, "../screenshots"); if (!existsSync(screenshotsDir)) { mkdirSync(screenshotsDir, { recursive: true }); } // Save screenshot const screenshotPath = path.join(screenshotsDir, `screenshot-${Date.now()}.jpg`); writeFileSync(screenshotPath, screenshot); // Close browser after taking screenshot await closeBrowser(); return { content: [ { type: "text" as const, text: `Screenshot captured. ${fullPage ? "(Full page)" : ""}`, }, { type: "text" as const, text: `Saved to: ${screenshotPath}`, }, { type: "image" as const, data: screenshot.toString("base64"), mimeType: "image/jpeg", }, ], }; } catch (error) { // Close browser even when an error occurs await closeBrowser(); return { content: [ { type: "text" as const, text: `An error occurred while taking screenshot: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); // Start server async function main() { try { // Create necessary directories const screenshotsDir = path.join(__dirname, "../screenshots"); if (!existsSync(screenshotsDir)) { mkdirSync(screenshotsDir, { recursive: true }); } // Start server const transport = new StdioServerTransport(); await server.connect(transport); } catch (error) { process.exit(1); } } // Cleanup function async function cleanup() { if (browser) { await browser.close(); } process.exit(0); } // Cleanup on shutdown process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); // Start server main().catch(() => { process.exit(1); });