# 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 |
```