UNPKG

pagespeed-insights-mcp

Version:

MCP server for Google PageSpeed Insights API - analyze web performance with Claude

728 lines 32.6 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { randomUUID } from "crypto"; import { validateEnv } from "./env.js"; import { getLogger, createRequestLogger } from "./logger.js"; import { PageSpeedClient } from "./pagespeed-client.js"; import { cache } from "./cache.js"; import { PerformanceRecommendationsEngine } from "./recommendations.js"; import { AnalyzePageSpeedSchema, PerformanceSummarySchema, CruxSummarySchema, CompareUrlsSchema, BatchAnalyzeSchema, } from "./schemas.js"; class PageSpeedInsightsServer { constructor() { this.logger = getLogger(); // Validate environment first validateEnv(); this.server = new Server({ name: "pagespeed-insights-mcp", version: "1.0.1", }, { capabilities: { tools: {}, }, }); this.client = new PageSpeedClient(); this.recommendationsEngine = new PerformanceRecommendationsEngine(); this.setupTools(); } setupTools() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "analyze_page_speed", description: "Run comprehensive Google PageSpeed Insights analysis with Lighthouse metrics", inputSchema: { type: "object", properties: { url: { type: "string", format: "uri", description: "The URL to analyze" }, strategy: { type: "string", enum: ["mobile", "desktop"], default: "mobile", description: "Analysis strategy" }, category: { type: "array", items: { type: "string", enum: ["performance", "accessibility", "best-practices", "seo", "pwa"] }, default: ["performance"], description: "Categories to analyze" }, locale: { type: "string", default: "en", description: "Locale for results" } }, required: ["url"] }, }, { name: "get_performance_summary", description: "Get simplified performance metrics and opportunities for a webpage", inputSchema: { type: "object", properties: { url: { type: "string", format: "uri", description: "The URL to analyze" }, strategy: { type: "string", enum: ["mobile", "desktop"], default: "mobile", description: "Analysis strategy" } }, required: ["url"] }, }, { name: "crux_summary", description: "Get Chrome User Experience Report real-world field data for Core Web Vitals", inputSchema: { type: "object", properties: { url: { type: "string", format: "uri", description: "The URL to analyze" }, formFactor: { type: "string", enum: ["PHONE", "DESKTOP", "TABLET"], description: "Form factor for CrUX data" } }, required: ["url"] }, }, { name: "compare_pages", description: "Compare performance metrics between two URLs side-by-side", inputSchema: { type: "object", properties: { urlA: { type: "string", format: "uri", description: "First URL to compare" }, urlB: { type: "string", format: "uri", description: "Second URL to compare" }, strategy: { type: "string", enum: ["mobile", "desktop"], default: "mobile", description: "Analysis strategy" }, categories: { type: "array", items: { type: "string", enum: ["performance", "accessibility", "best-practices", "seo", "pwa"] }, default: ["performance"], description: "Categories to compare" } }, required: ["urlA", "urlB"] }, }, { name: "full_report", description: "Unified report combining Lighthouse lab data with CrUX field data", inputSchema: { type: "object", properties: { url: { type: "string", format: "uri", description: "The URL to analyze" }, strategy: { type: "string", enum: ["mobile", "desktop"], default: "mobile", description: "Analysis strategy" }, category: { type: "array", items: { type: "string", enum: ["performance", "accessibility", "best-practices", "seo", "pwa"] }, default: ["performance"], description: "Categories to analyze" }, locale: { type: "string", default: "en", description: "Locale for results" } }, required: ["url"] }, }, { name: "batch_analyze", description: "Analyze performance for multiple URLs with progress tracking", inputSchema: { type: "object", properties: { urls: { type: "array", items: { type: "string", format: "uri" }, minItems: 1, maxItems: 10, description: "URLs to analyze (max 10)" }, strategy: { type: "string", enum: ["mobile", "desktop"], default: "mobile", description: "Analysis strategy" }, category: { type: "array", items: { type: "string", enum: ["performance", "accessibility", "best-practices", "seo", "pwa"] }, default: ["performance"], description: "Categories to analyze" }, locale: { type: "string", default: "en", description: "Locale for results" } }, required: ["urls"] }, }, { name: "clear_cache", description: "Clear the internal cache to force fresh API requests", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "get_recommendations", description: "Generate smart performance recommendations with priority scoring and actionable fixes", inputSchema: { type: "object", properties: { url: { type: "string", format: "uri", description: "The URL to analyze for recommendations" }, strategy: { type: "string", enum: ["mobile", "desktop"], default: "mobile", description: "Analysis strategy" }, category: { type: "array", items: { type: "string", enum: ["performance", "accessibility", "best-practices", "seo", "pwa"] }, default: ["performance", "accessibility", "best-practices", "seo"], description: "Categories to analyze for recommendations" }, locale: { type: "string", default: "en", description: "Locale for results" } }, required: ["url"] }, }, ], }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "analyze_page_speed": return await this.handleAnalyzePageSpeed(args); case "get_performance_summary": return await this.handlePerformanceSummary(args); case "crux_summary": return await this.handleCruxSummary(args); case "compare_pages": return await this.handleComparePages(args); case "full_report": return await this.handleFullReport(args); case "batch_analyze": return await this.handleBatchAnalyze(args); case "clear_cache": return await this.handleClearCache(); case "get_recommendations": return await this.handleGetRecommendations(args); default: throw new Error(`Unknown tool: ${name}`); } }); } async handleAnalyzePageSpeed(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "analyze-page-speed"); try { const input = AnalyzePageSpeedSchema.parse(args); logger.info({ url: input.url, strategy: input.strategy }, "Starting PageSpeed analysis"); const result = await this.client.analyzePageSpeed(input, correlationId); return { content: [ { type: "text", text: this.formatAnalysisReport(result, input), }, { type: "resource", resource: { uri: `data:application/json;base64,${Buffer.from(JSON.stringify(result, null, 2)).toString('base64')}`, name: "raw_pagespeed_data.json", mimeType: "application/json", }, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "PageSpeed analysis failed"); return { content: [ { type: "text", text: `Error analyzing page speed: ${errorMessage}`, }, ], isError: true, }; } } async handlePerformanceSummary(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "performance-summary"); try { const input = PerformanceSummarySchema.parse(args); logger.info({ url: input.url }, "Getting performance summary"); const fullInput = { ...input, category: ["performance"], locale: "en", }; const result = await this.client.analyzePageSpeed(fullInput, correlationId); const summary = this.createPerformanceSummary(result, input); return { content: [ { type: "text", text: JSON.stringify(summary, null, 2), }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "Performance summary failed"); return { content: [ { type: "text", text: `Error getting performance summary: ${errorMessage}`, }, ], isError: true, }; } } async handleCruxSummary(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "crux-summary"); try { const input = CruxSummarySchema.parse(args); logger.info({ url: input.url }, "Getting CrUX summary"); const cruxData = await this.client.getCruxData(input, correlationId); const summary = this.formatCruxSummary(cruxData, input.url); return { content: [ { type: "text", text: summary, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "CrUX summary failed"); return { content: [ { type: "text", text: `Error getting CrUX data: ${errorMessage}`, }, ], isError: true, }; } } async handleComparePages(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "compare-pages"); try { const input = CompareUrlsSchema.parse(args); logger.info({ urlA: input.urlA, urlB: input.urlB }, "Comparing pages"); const [resultA, resultB] = await Promise.all([ this.client.analyzePageSpeed({ url: input.urlA, strategy: input.strategy, category: input.categories, locale: "en", }, correlationId), this.client.analyzePageSpeed({ url: input.urlB, strategy: input.strategy, category: input.categories, locale: "en", }, correlationId), ]); const comparison = this.createComparison(resultA, resultB, input); return { content: [ { type: "text", text: JSON.stringify(comparison, null, 2), }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "Page comparison failed"); return { content: [ { type: "text", text: `Error comparing pages: ${errorMessage}`, }, ], isError: true, }; } } async handleFullReport(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "full-report"); try { const input = AnalyzePageSpeedSchema.parse(args); logger.info({ url: input.url }, "Generating full Lab+Field report"); const [psiData, cruxData] = await Promise.allSettled([ this.client.analyzePageSpeed(input, correlationId), this.client.getCruxData({ url: input.url }, correlationId), ]); const report = this.createFullReport(psiData.status === "fulfilled" ? psiData.value : null, cruxData.status === "fulfilled" ? cruxData.value : null, input); return { content: [ { type: "text", text: report, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "Full report generation failed"); return { content: [ { type: "text", text: `Error generating full report: ${errorMessage}`, }, ], isError: true, }; } } async handleBatchAnalyze(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "batch-analyze"); try { const input = BatchAnalyzeSchema.parse(args); logger.info({ urlCount: input.urls.length }, "Starting batch analysis"); const results = []; for (let i = 0; i < input.urls.length; i++) { const url = input.urls[i]; try { logger.info({ url, progress: `${i + 1}/${input.urls.length}` }, "Analyzing URL"); const result = await this.client.analyzePageSpeed({ url, strategy: input.strategy, category: input.category, locale: input.locale, }, correlationId); results.push({ url, result: this.createPerformanceSummary(result, { url, strategy: input.strategy }) }); } catch (error) { const urlErrorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.warn({ url, error: urlErrorMessage }, "URL analysis failed"); results.push({ url, error: urlErrorMessage }); } } return { content: [ { type: "text", text: JSON.stringify({ summary: { total: input.urls.length, successful: results.filter(r => r.result).length, failed: results.filter(r => r.error).length, }, results, }, null, 2), }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "Batch analysis failed"); return { content: [ { type: "text", text: `Error in batch analysis: ${errorMessage}`, }, ], isError: true, }; } } async handleClearCache() { const logger = this.logger; try { const sizeBefore = cache.size(); cache.clear(); logger.info({ clearedEntries: sizeBefore }, "Cache cleared successfully"); return { content: [ { type: "text", text: `✅ Cache cleared successfully. Removed ${sizeBefore} cached entries.`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "Failed to clear cache"); return { content: [ { type: "text", text: `❌ Error clearing cache: ${errorMessage}`, }, ], isError: true, }; } } async handleGetRecommendations(args) { const correlationId = randomUUID(); const logger = createRequestLogger(correlationId, "get-recommendations"); try { const input = AnalyzePageSpeedSchema.parse(args); logger.info({ url: input.url, strategy: input.strategy }, "Generating performance recommendations"); const result = await this.client.analyzePageSpeed(input, correlationId); const recommendations = this.recommendationsEngine.generateRecommendations(result); const formattedReport = this.recommendationsEngine.formatRecommendations(recommendations); return { content: [ { type: "text", text: formattedReport, }, { type: "resource", resource: { uri: `data:application/json;base64,${Buffer.from(JSON.stringify(recommendations, null, 2)).toString('base64')}`, name: "recommendations_data.json", mimeType: "application/json", }, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; logger.error({ error: errorMessage }, "Recommendations generation failed"); return { content: [ { type: "text", text: `Error generating recommendations: ${errorMessage}`, }, ], isError: true, }; } } formatAnalysisReport(data, input) { const lighthouse = data.lighthouseResult; if (!lighthouse) { return "No Lighthouse data available in response"; } const performance = lighthouse.categories?.performance; const audits = lighthouse.audits; let report = `# PageSpeed Insights Analysis\n\n`; report += `**URL:** ${input.url}\n`; report += `**Strategy:** ${input.strategy}\n`; report += `**Analysis Time:** ${data.analysisUTCTimestamp}\n\n`; if (performance) { report += `## Performance Score: ${Math.round(performance.score * 100)}/100\n\n`; } report += `## Core Web Vitals\n`; const cwvMetrics = ["largest-contentful-paint", "first-input-delay", "cumulative-layout-shift"]; cwvMetrics.forEach(metric => { const audit = audits?.[metric]; if (audit) { report += `- **${audit.title}**: ${audit.displayValue} ${audit.score === 1 ? "✅" : audit.score >= 0.9 ? "⚠️" : "❌"}\n`; } }); report += `\n## Key Metrics\n`; const keyMetrics = ["first-contentful-paint", "speed-index", "total-blocking-time"]; keyMetrics.forEach(metric => { const audit = audits?.[metric]; if (audit) { report += `- **${audit.title}**: ${audit.displayValue}\n`; } }); if (performance?.auditRefs) { const opportunities = performance.auditRefs .filter(ref => audits?.[ref.id]?.details?.type === "opportunity") .slice(0, 5); if (opportunities.length > 0) { report += `\n## Top Opportunities\n`; opportunities.forEach(ref => { const audit = audits?.[ref.id]; if (audit) { report += `- **${audit.title}**: ${audit.displayValue || "See details"}\n`; } }); } } return report; } createPerformanceSummary(data, input) { const lighthouse = data.lighthouseResult; const performance = lighthouse?.categories?.performance; const audits = lighthouse?.audits; return { url: input.url, strategy: input.strategy, timestamp: data.analysisUTCTimestamp, performance: { score: performance?.score ? Math.round(performance.score * 100) : null, metrics: { firstContentfulPaint: audits?.["first-contentful-paint"]?.displayValue, largestContentfulPaint: audits?.["largest-contentful-paint"]?.displayValue, cumulativeLayoutShift: audits?.["cumulative-layout-shift"]?.displayValue, speedIndex: audits?.["speed-index"]?.displayValue, totalBlockingTime: audits?.["total-blocking-time"]?.displayValue, firstInputDelay: audits?.["max-potential-fid"]?.displayValue, }, }, opportunities: performance?.auditRefs ?.filter(ref => audits?.[ref.id]?.details?.type === "opportunity") ?.map(ref => ({ id: ref.id, title: audits?.[ref.id]?.title, description: audits?.[ref.id]?.description, score: audits?.[ref.id]?.score, displayValue: audits?.[ref.id]?.displayValue, })) ?.slice(0, 5) || [], }; } formatCruxSummary(cruxData, url) { if (!cruxData.record) { return `# CrUX Field Data\n\n**URL:** ${url}\n**Status:** No field data available (insufficient traffic)\n\nThis URL doesn't have enough real-world usage data in Chrome UX Report.`; } const { record } = cruxData; let summary = `# CrUX Field Data Summary\n\n`; summary += `**URL:** ${url}\n`; summary += `**Form Factor:** ${record.key.formFactor}\n\n`; const cwvMetrics = { "largest_contentful_paint": "Largest Contentful Paint", "first_input_delay": "First Input Delay", "cumulative_layout_shift": "Cumulative Layout Shift", }; summary += `## Core Web Vitals (Real User Data)\n`; Object.entries(cwvMetrics).forEach(([key, title]) => { const metric = record.metrics?.[key]; if (metric) { summary += `- **${title}**: ${metric.percentiles.p75}${key === "cumulative_layout_shift" ? "" : "ms"} (p75)\n`; } }); return summary; } createComparison(resultA, resultB, input) { const scoreA = resultA.lighthouseResult?.categories?.performance?.score || 0; const scoreB = resultB.lighthouseResult?.categories?.performance?.score || 0; const auditsA = resultA.lighthouseResult?.audits || {}; const auditsB = resultB.lighthouseResult?.audits || {}; const keyMetrics = ["largest-contentful-paint", "total-blocking-time", "cumulative-layout-shift"]; const metrics = {}; keyMetrics.forEach(metric => { const auditA = auditsA[metric]; const auditB = auditsB[metric]; if (auditA && auditB) { metrics[metric] = { urlA: auditA.displayValue, urlB: auditB.displayValue, better: auditA.score > auditB.score ? 'A' : auditA.score < auditB.score ? 'B' : 'tie', }; } }); return { urlA: input.urlA, urlB: input.urlB, strategy: input.strategy, comparison: { scores: { urlA: Math.round(scoreA * 100), urlB: Math.round(scoreB * 100), difference: Math.round((scoreA - scoreB) * 100), }, metrics, }, }; } createFullReport(psiData, cruxData, input) { let report = `# Full Performance Report (Lab + Field)\n\n`; report += `**URL:** ${input.url}\n`; report += `**Strategy:** ${input.strategy}\n\n`; if (psiData) { report += this.formatAnalysisReport(psiData, input); report += `\n\n---\n\n`; } if (cruxData) { report += this.formatCruxSummary(cruxData, input.url); } else { report += `## Real User Experience (CrUX)\nNo field data available for this URL.\n`; } if (psiData && cruxData?.record) { report += `\n\n## Lab vs Field Comparison\n`; report += `Lab data represents controlled testing conditions, while field data shows real user experiences.\n`; } return report; } async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); this.logger.info("PageSpeed Insights MCP server started"); } } const server = new PageSpeedInsightsServer(); server.start().catch((error) => { console.error("Server failed to start:", error); process.exit(1); }); //# sourceMappingURL=index.js.map