#
tokens: 6223/50000 5/5 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   └── index.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Logs
 2 | logs
 3 | *.log
 4 | 
 5 | # Dependency directories
 6 | node_modules/
 7 | 
 8 | # Build directories
 9 | build/
10 | 
11 | # Outputs
12 | reports/
13 | screenshots/
14 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Playwright-Lighthouse MCP Server
 2 | 
 3 | A MCP server that analyzes web site performance using Playwright and Lighthouse. Through the Model Context Protocol (MCP), LLMs can perform web site performance analysis.
 4 | 
 5 | ## Features
 6 | 
 7 | - Performance analysis with Lighthouse
 8 | - Screenshot capture
 9 | 
10 | ## Setup
11 | 
12 | ### Prerequisites
13 | 
14 | - Node.js 18 or higher
15 | - npm
16 | 
17 | ### Installation
18 | 
19 | ```bash
20 | # Clone the repository
21 | git clone https://github.com/kbyk004/playwright-lighthouse-mcp.git
22 | cd playwright-lighthouse-mcp
23 | 
24 | # Install dependencies
25 | npm install
26 | npx playwright install
27 | 
28 | # Build
29 | npm run build
30 | ```
31 | 
32 | ## Usage
33 | 
34 | ### Debugging MCP Server
35 | 
36 | ```bash
37 | npm run inspector
38 | ```
39 | 
40 | ### Integration with MCP Clients
41 | 
42 | This server is designed to be used with clients that support the Model Context Protocol (MCP). For example, it can be integrated with Claude for Desktop.
43 | 
44 | #### Configuration Example for Claude for Desktop
45 | 
46 | Add the following to the Claude for Desktop configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json`):
47 | 
48 | ```json
49 | {
50 |   "mcpServers": {
51 |     "playwright-lighthouse": {
52 |       "command": "node",
53 |       "args": [
54 |         "/path-to/playwright-lighthouse-mcp/build/index.js"
55 |       ]
56 |     }
57 |   }
58 | }
59 | ```
60 | 
61 | ## Available Tools
62 | 
63 | ### 1. run-lighthouse
64 | 
65 | Runs a Lighthouse performance analysis on the currently open page.
66 | 
67 | Parameters:
68 | - `url`: The URL of the website you want to analyze
69 | - `categories`: Array of categories to analyze (default: ["performance"])
70 |   - Available categories: "performance", "accessibility", "best-practices", "seo", "pwa"
71 | - `maxItems`: Maximum number of improvement items to display for each category (default: 3, max: 5)
72 | 
73 | ### 2. take-screenshot
74 | 
75 | Takes a screenshot of the currently open page.
76 | 
77 | Parameters:
78 | - `url`: The URL of the website you want to capture
79 | - `fullPage`: If true, captures a screenshot of the entire page (default: false)
80 | 
81 | ## Output Format
82 | 
83 | The analysis results include:
84 | 
85 | - Overall scores for each selected category with color indicators
86 | - Key improvement areas grouped by category
87 | - Path to the saved report file
88 | 
89 | ## License
90 | 
91 | MIT License - see [LICENSE](LICENSE) for details
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2022",
 4 |     "module": "Node16",
 5 |     "moduleResolution": "Node16",
 6 |     "outDir": "./build",
 7 |     "rootDir": "./src",
 8 |     "strict": true,
 9 |     "esModuleInterop": true,
10 |     "skipLibCheck": true,
11 |     "forceConsistentCasingInFileNames": true
12 |   },
13 |   "include": ["src/**/*"],
14 |   "exclude": ["node_modules"]
15 | }
16 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "playwright-lighthouse-mcp",
 3 |   "version": "1.0.0",
 4 |   "type": "module",
 5 |   "main": "build/index.js",
 6 |   "scripts": {
 7 |     "build": "tsc",
 8 |     "start": "node build/index.js",
 9 |     "dev": "ts-node --esm src/index.ts",
10 |     "test": "echo \"Error: no test specified\" && exit 1",
11 |     "inspector": "npx @modelcontextprotocol/inspector node build/index.js"
12 |   },
13 |   "keywords": [],
14 |   "author": "kbyk004",
15 |   "license": "MIT",
16 |   "description": "A MCP server that analyzes web site performance using Playwright and Lighthouse.",
17 |   "dependencies": {
18 |     "@modelcontextprotocol/sdk": "^1.6.1",
19 |     "@types/node": "^22.13.10",
20 |     "playwright": "^1.51.0",
21 |     "playwright-lighthouse": "^4.0.0",
22 |     "ts-node": "^10.9.2",
23 |     "typescript": "^5.8.2",
24 |     "zod": "^3.24.2"
25 |   }
26 | }
27 | 
```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  3 | import { z } from "zod";
  4 | import { chromium, Browser, Page } from "playwright";
  5 | import { playAudit } from "playwright-lighthouse";
  6 | import { writeFileSync, existsSync, readFileSync, readdirSync, mkdirSync, statSync } from "fs";
  7 | import path from "path";
  8 | import { fileURLToPath } from "url";
  9 | 
 10 | // Get the current directory path
 11 | const __filename = fileURLToPath(import.meta.url);
 12 | const __dirname = path.dirname(__filename);
 13 | 
 14 | // Create directory for saving reports
 15 | const reportsDir = path.join(__dirname, "../reports");
 16 | if (!existsSync(reportsDir)) {
 17 |   mkdirSync(reportsDir, { recursive: true });
 18 | }
 19 | 
 20 | // Create MCP server
 21 | const server = new McpServer({
 22 |   name: "playwright-lighthouse",
 23 |   version: "1.0.0",
 24 | });
 25 | 
 26 | // Variable to hold browser instance
 27 | let browser: Browser | null = null;
 28 | let page: Page | null = null;
 29 | 
 30 | // Function to launch browser
 31 | async function launchBrowser() {
 32 |   if (!browser) {
 33 |     // Launch browser with remote debugging port
 34 |     browser = await chromium.launch({
 35 |       headless: true,
 36 |       args: [
 37 |         '--remote-debugging-port=9222',
 38 |         '--ignore-certificate-errors'
 39 |       ],
 40 |       timeout: 30000,
 41 |     });
 42 |   }
 43 |   return browser;
 44 | }
 45 | 
 46 | // Function to open a page
 47 | async function getPage() {
 48 |   if (!page) {
 49 |     const browser = await launchBrowser();
 50 |     page = await browser.newPage();
 51 |   }
 52 |   return page;
 53 | }
 54 | 
 55 | // Function to navigate to URL
 56 | async function navigateToUrl(url: string) {
 57 |   try {
 58 |     const page = await getPage();
 59 |     await page.goto(url, { waitUntil: "load" });
 60 |     return page;
 61 |   } catch (error) {
 62 |     throw error;
 63 |   }
 64 | }
 65 | 
 66 | // Function to close browser
 67 | async function closeBrowser() {
 68 |   if (browser) {
 69 |     await browser.close();
 70 |     browser = null;
 71 |     page = null;
 72 |   }
 73 | }
 74 | 
 75 | // Tool 1: Run Lighthouse performance analysis
 76 | server.tool(
 77 |   "run-lighthouse",
 78 |   "Runs a Lighthouse performance analysis on the currently open page",
 79 |   {
 80 |     url: z.string().url().describe("URL of the website you want to analyze"),
 81 |     categories: z.array(z.enum(["performance", "accessibility", "best-practices", "seo", "pwa"]))
 82 |       .default(["performance"])
 83 |       .describe("Categories to analyze (performance, accessibility, best-practices, seo, pwa)"),
 84 |     maxItems: z.number().min(1).max(5).default(3)
 85 |       .describe("Maximum number of improvement items to display for each category"),
 86 |   },
 87 |   async (params, extra): Promise<{
 88 |     content: { type: "text"; text: string }[];
 89 |     isError?: boolean;
 90 |   }> => {
 91 |     try {
 92 |       // Automatically launch browser and navigate to URL
 93 |       await navigateToUrl(params.url);
 94 | 
 95 |       const url = page!.url();
 96 | 
 97 |       try {
 98 |         // CDP connection method for the latest Playwright version
 99 |         const browserContext = browser!.contexts()[0];
100 |         const cdpSession = await browserContext.newCDPSession(page!);
101 |         
102 |         // Get browser version information to check debug port
103 |         const versionInfo = await cdpSession.send('Browser.getVersion');
104 |         
105 |         // Get port number from WebSocket debugger URL
106 |         // Note: Using the port specified at launch (9222)
107 |         const port = 9222;
108 | 
109 |         // Function to run Lighthouse audit
110 |         const runAudit = async () => {
111 |           try {
112 |             // Create report path
113 |             const hostname = new URL(url).hostname.replace(/\./g, '-');
114 |             const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
115 |             const reportPath = path.join(__dirname, `../reports/lighthouse-${hostname}-${timestamp}.json`);
116 |             
117 |             try {
118 |               // Run Lighthouse audit
119 |               const results = await playAudit({
120 |                 page: page!,
121 |                 port: port,
122 |                 thresholds: {
123 |                   performance: 0,
124 |                   accessibility: 0,
125 |                   'best-practices': 0,
126 |                   seo: 0,
127 |                   pwa: 0
128 |                 },
129 |                 reports: {
130 |                   formats: {
131 |                     html: false,
132 |                     json: true
133 |                   },
134 |                   directory: path.join(__dirname, "../reports"),
135 |                   name: `lighthouse-${hostname}-${timestamp}`
136 |                 },
137 |                 ignoreError: true,
138 |                 config: {
139 |                   extends: 'lighthouse:default'
140 |                 }
141 |               });
142 |               
143 |               // Function to represent score evaluation with color
144 |               const getScoreEmoji = (score: number): string => {
145 |                 if (score >= 90) return "🟢"; // Good
146 |                 if (score >= 50) return "🟠"; // Average
147 |                 return "🔴"; // Poor
148 |               };
149 | 
150 |               // Process results directly
151 |               let scoreText = "📊 Lighthouse Scores:\n";
152 |               let improvementText = "\n\n🔍 Key Improvement Areas:";
153 |               
154 |               // Prepare arrays to store improvement items
155 |               const improvementItems: { category: string; title: string; description: string }[] = [];
156 |               
157 |               // Check if results are available directly
158 |               if (results && results.lhr && results.lhr.categories) {
159 |                 // Get selected categories from the direct results
160 |                 const availableCategories = Object.keys(results.lhr.categories);
161 |                 
162 |                 // Filter categories based on user selection
163 |                 const selectedCategories = params.categories.filter(cat => 
164 |                   availableCategories.includes(cat)
165 |                 );
166 |                 
167 |                 // Process each category
168 |                 for (const category of selectedCategories) {
169 |                   const categoryData = results.lhr.categories[category];
170 |                   
171 |                   if (categoryData) {
172 |                     // Get all audits for this category
173 |                     const audits = results.lhr.audits;
174 |                     const categoryAudits = Object.keys(audits).filter(
175 |                       auditId => {
176 |                         const audit = audits[auditId];
177 |                         return audit.details && 
178 |                                categoryData.auditRefs.some((ref: any) => ref.id === auditId);
179 |                       }
180 |                     );
181 |                     
182 |                     // Get score
183 |                     let scoreDisplay = '';
184 |                     
185 |                     if (categoryData.score === null) {
186 |                       // When score cannot be calculated
187 |                       scoreDisplay = `⚪️ ${category.charAt(0).toUpperCase() + category.slice(1)}: Not measurable`;
188 |                     } else {
189 |                       // When score can be calculated
190 |                       const score = Math.round(categoryData.score * 100);
191 |                       scoreDisplay = `${getScoreEmoji(score)} ${category.charAt(0).toUpperCase() + category.slice(1)}: ${score}/100`;
192 |                     }
193 |                     
194 |                     // Add score to response
195 |                     scoreText += scoreDisplay + '\n';
196 |                     
197 |                     // Collect improvement items
198 |                     for (const auditId of categoryAudits) {
199 |                       const audit = audits[auditId];
200 |                       if ((audit.score || 0) < 0.9) {
201 |                         improvementItems.push({
202 |                           category,
203 |                           title: audit.title,
204 |                           description: audit.description,
205 |                         });
206 |                       }
207 |                     }
208 |                   }
209 |                 }
210 |               } else {
211 |                 // Fallback to reading from file if direct results are not available
212 |                 try {
213 |                   // Load JSON file
214 |                   if (existsSync(reportPath)) {
215 |                     // Read and parse JSON file
216 |                     const jsonData = JSON.parse(readFileSync(reportPath, 'utf8'));
217 |                     
218 |                     if (jsonData && jsonData.categories) {
219 |                       // Get selected categories from the report
220 |                       const availableCategories = Object.keys(jsonData.categories);
221 |                       
222 |                       // Filter categories based on user selection
223 |                       const selectedCategories = params.categories.filter(cat => 
224 |                         availableCategories.includes(cat)
225 |                       );
226 |                       
227 |                       // Process each category
228 |                       for (const category of selectedCategories) {
229 |                         const categoryData = jsonData.categories[category];
230 |                         
231 |                         if (categoryData) {
232 |                           // Get all audits for this category
233 |                           const audits = jsonData.audits;
234 |                           const categoryAudits = Object.keys(audits).filter(
235 |                             auditId => {
236 |                               const audit = audits[auditId];
237 |                               return audit.details && 
238 |                                      categoryData.auditRefs.some((ref: any) => ref.id === auditId);
239 |                             }
240 |                           );
241 |                           
242 |                           // Get score
243 |                           let scoreDisplay = '';
244 |                           
245 |                           if (categoryData.score === null) {
246 |                             // When score cannot be calculated
247 |                             scoreDisplay = `⚪️ ${category.charAt(0).toUpperCase() + category.slice(1)}: Not measurable`;
248 |                           } else {
249 |                             // When score can be calculated
250 |                             const score = Math.round(categoryData.score * 100);
251 |                             scoreDisplay = `${getScoreEmoji(score)} ${category.charAt(0).toUpperCase() + category.slice(1)}: ${score}/100`;
252 |                           }
253 |                           
254 |                           // Add score to response
255 |                           scoreText += scoreDisplay + '\n';
256 |                           
257 |                           // Collect improvement items
258 |                           for (const auditId of categoryAudits) {
259 |                             const audit = audits[auditId];
260 |                             if ((audit.score || 0) < 0.9) {
261 |                               improvementItems.push({
262 |                                 category,
263 |                                 title: audit.title,
264 |                                 description: audit.description,
265 |                               });
266 |                             }
267 |                           }
268 |                         }
269 |                       }
270 |                     }
271 |                   } else {
272 |                     // List all files in directory
273 |                     const files = readdirSync(path.join(__dirname, "../reports"));
274 |                     
275 |                     // Find the latest JSON file
276 |                     const jsonFiles = files.filter(file => file.endsWith('.json'));
277 |                     if (jsonFiles.length > 0) {
278 |                       const latestFile = jsonFiles.sort().pop();
279 |                       
280 |                       // Use the latest file
281 |                       const latestPath = path.join(__dirname, "../reports", latestFile || '');
282 |                       try {
283 |                         const latestData = JSON.parse(readFileSync(latestPath, 'utf8'));
284 |                         
285 |                         if (latestData && latestData.categories) {
286 |                           // Process each category
287 |                           for (const category of params.categories) {
288 |                             const categoryData = latestData.categories[category];
289 |                             
290 |                             if (categoryData) {
291 |                               // Get all audits for this category
292 |                               const audits = latestData.audits;
293 |                               const categoryAudits = Object.keys(audits).filter(
294 |                                 auditId => {
295 |                                   const audit = audits[auditId];
296 |                                   return audit.details && 
297 |                                          categoryData.auditRefs.some((ref: any) => ref.id === auditId);
298 |                                 }
299 |                               );
300 |                               
301 |                               // Get score
302 |                               let scoreDisplay = '';
303 |                               
304 |                               if (categoryData.score === null) {
305 |                                 // When score cannot be calculated
306 |                                 scoreDisplay = `⚪️ ${category.charAt(0).toUpperCase() + category.slice(1)}: Not measurable`;
307 |                               } else {
308 |                                 // When score can be calculated
309 |                                 const score = Math.round(categoryData.score * 100);
310 |                                 scoreDisplay = `${getScoreEmoji(score)} ${category.charAt(0).toUpperCase() + category.slice(1)}: ${score}/100`;
311 |                               }
312 |                               
313 |                               // Add score to response
314 |                               scoreText += scoreDisplay + '\n';
315 |                               
316 |                               // Collect improvement items
317 |                               for (const auditId of categoryAudits) {
318 |                                 const audit = audits[auditId];
319 |                                 if ((audit.score || 0) < 0.9) {
320 |                                   improvementItems.push({
321 |                                     category,
322 |                                     title: audit.title,
323 |                                     description: audit.description,
324 |                                   });
325 |                                 }
326 |                               }
327 |                             }
328 |                           }
329 |                         }
330 |                       } catch (err: any) {
331 |                         throw new Error(`Failed to read latest JSON file: ${err.message}`);
332 |                       }
333 |                     } else {
334 |                       throw new Error('Lighthouse report file not found.');
335 |                     }
336 |                   }
337 |                 } catch (error) {
338 |                   throw error; // Propagate to higher error handler
339 |                 }
340 |               }
341 | 
342 |               // Display improvement points (sorted by weight)
343 |               if (improvementItems.length > 0) {
344 |                 // Sort by category
345 |                 improvementItems.sort((a, b) => {
346 |                   if (a.category !== b.category) {
347 |                     return a.category.localeCompare(b.category);
348 |                   }
349 |                   return a.title.localeCompare(b.title);
350 |                 });
351 |                 
352 |                 // Group and display
353 |                 let currentCategory = '';
354 |                 for (const imp of improvementItems.slice(0, params.maxItems * params.categories.length)) {
355 |                   if (currentCategory !== imp.category) {
356 |                     currentCategory = imp.category;
357 |                     // Display category name appropriately
358 |                     const categoryDisplayName = {
359 |                       'performance': 'Performance',
360 |                       'accessibility': 'Accessibility',
361 |                       'best-practices': 'Best Practices',
362 |                       'seo': 'SEO',
363 |                       'pwa': 'PWA'
364 |                     }[imp.category] || imp.category;
365 |                     
366 |                     improvementText += `\n\n【${categoryDisplayName}】Improvement items:`;
367 |                   }
368 |                   improvementText += `\n・${imp.title}`;
369 |                 }
370 |               } else {
371 |                 improvementText += "\n\nNo improvement items found.";
372 |               }
373 | 
374 |               // Close browser automatically after analysis is complete
375 |               await closeBrowser();
376 | 
377 |               // Return the results
378 |               return {
379 |                 content: [
380 |                   {
381 |                     type: "text" as const,
382 |                     text: scoreText + improvementText,
383 |                   },
384 |                   {
385 |                     type: "text" as const,
386 |                     text: `report save path: ${reportPath}`,
387 |                   },
388 |                 ],
389 |               };
390 |             } catch (error: any) {
391 |               // Close browser even when an error occurs
392 |               await closeBrowser();
393 |               
394 |               throw error; // Propagate to higher error handler
395 |             }
396 |           } catch (error: any) {
397 |             // Close browser even when an error occurs
398 |             await closeBrowser();
399 |             
400 |             return {
401 |               content: [
402 |                 {
403 |                   type: "text" as const,
404 |                   text: `An error occurred during Lighthouse analysis: ${error instanceof Error ? error.message : String(error)}`,
405 |                 },
406 |               ],
407 |               isError: true,
408 |             };
409 |           }
410 |         };
411 | 
412 |         return await runAudit();
413 |       } catch (error: any) {
414 |         // Close browser even when an error occurs
415 |         await closeBrowser();
416 |         
417 |         return {
418 |           content: [
419 |             {
420 |               type: "text" as const,
421 |               text: `An error occurred during Lighthouse analysis: ${error instanceof Error ? error.message : String(error)}`,
422 |             },
423 |           ],
424 |           isError: true,
425 |         };
426 |       }
427 |     } catch (error: any) {
428 |       // Close browser even when an error occurs
429 |       await closeBrowser();
430 | 
431 |       return {
432 |         content: [
433 |           {
434 |             type: "text" as const,
435 |             text: `An error occurred during Lighthouse analysis: ${error instanceof Error ? error.message : String(error)}`,
436 |           },
437 |         ],
438 |         isError: true,
439 |       };
440 |     }
441 |   }
442 | );
443 | 
444 | // Tool 2: Take screenshot
445 | server.tool(
446 |   "take-screenshot",
447 |   "Takes a screenshot of the currently open page",
448 |   {
449 |     url: z.string().url().describe("URL of the website you want to capture"),
450 |     fullPage: z.boolean().default(false).describe("If true, captures a screenshot of the entire page"),
451 |   },
452 |   async ({ url, fullPage }) => {
453 |     try {
454 |       // Automatically launch browser and navigate to URL
455 |       await navigateToUrl(url);
456 | 
457 |       const screenshot = await page!.screenshot({ fullPage, type: "jpeg", quality: 80 });
458 |       
459 |       // Create directory for screenshots if it doesn't exist
460 |       const screenshotsDir = path.join(__dirname, "../screenshots");
461 |       if (!existsSync(screenshotsDir)) {
462 |         mkdirSync(screenshotsDir, { recursive: true });
463 |       }
464 |       
465 |       // Save screenshot
466 |       const screenshotPath = path.join(screenshotsDir, `screenshot-${Date.now()}.jpg`);
467 |       writeFileSync(screenshotPath, screenshot);
468 | 
469 |       // Close browser after taking screenshot
470 |       await closeBrowser();
471 |       
472 |       return {
473 |         content: [
474 |           {
475 |             type: "text" as const,
476 |             text: `Screenshot captured. ${fullPage ? "(Full page)" : ""}`,
477 |           },
478 |           {
479 |             type: "text" as const,
480 |             text: `Saved to: ${screenshotPath}`,
481 |           },
482 |           {
483 |             type: "image" as const,
484 |             data: screenshot.toString("base64"),
485 |             mimeType: "image/jpeg",
486 |           },
487 |         ],
488 |       };
489 |     } catch (error) {
490 |       // Close browser even when an error occurs
491 |       await closeBrowser();
492 |       
493 |       return {
494 |         content: [
495 |           {
496 |             type: "text" as const,
497 |             text: `An error occurred while taking screenshot: ${error instanceof Error ? error.message : String(error)}`,
498 |           },
499 |         ],
500 |         isError: true,
501 |       };
502 |     }
503 |   }
504 | );
505 | 
506 | // Start server
507 | async function main() {
508 |   try {
509 |     // Create necessary directories
510 |     const screenshotsDir = path.join(__dirname, "../screenshots");
511 |     if (!existsSync(screenshotsDir)) {
512 |       mkdirSync(screenshotsDir, { recursive: true });
513 |     }
514 | 
515 |     // Start server
516 |     const transport = new StdioServerTransport();
517 |     await server.connect(transport);
518 |   } catch (error) {
519 |     process.exit(1);
520 |   }
521 | }
522 | 
523 | // Cleanup function
524 | async function cleanup() {
525 |   if (browser) {
526 |     await browser.close();
527 |   }
528 |   process.exit(0);
529 | }
530 | 
531 | // Cleanup on shutdown
532 | process.on("SIGINT", cleanup);
533 | process.on("SIGTERM", cleanup);
534 | 
535 | // Start server
536 | main().catch(() => {
537 |   process.exit(1);
538 | });
539 | 
```