pagespeed-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/index.js +901 -0
  4. package/package.json +60 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 PhialsBasement
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # PageSpeed MCP Server
2
+
3
+ A Model Context Protocol (MCP) server for PageSpeed & SEO audits. **Zero configuration required** - just install and use.
4
+
5
+ ## Features
6
+
7
+ 7 powerful SEO audit tools:
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+ | `run_pagespeed_test` | Full PageSpeed Insights data (performance, accessibility, SEO, best practices) |
12
+ | `technical_seo_audit` | Meta tags, canonical URLs, hreflang, Open Graph, Twitter Cards, JSON-LD structured data |
13
+ | `sitemap_robots_audit` | Validate sitemap.xml and robots.txt |
14
+ | `onpage_seo_audit` | Heading structure, word count, internal/external links, images, keyword analysis |
15
+ | `security_headers_audit` | HTTPS, HSTS, CSP, X-Frame-Options, and more |
16
+ | `performance_audit` | Core Web Vitals summary with optimization opportunities |
17
+ | `mobile_audit` | Mobile-friendliness: viewport, tap targets, font sizes, responsive images, PWA readiness |
18
+
19
+ ## Quick Start
20
+
21
+ Add this to your Claude Desktop config:
22
+
23
+ **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
24
+ **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "pagespeed": {
30
+ "command": "npx",
31
+ "args": ["-y", "pagespeed-mcp-server"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Restart Claude Desktop. That's it!
38
+
39
+ ## Example Prompts
40
+
41
+ - "Run an SEO audit on https://example.com"
42
+ - "Check the Core Web Vitals for my website"
43
+ - "Is my site mobile-friendly?"
44
+ - "Analyze https://mysite.com/blog for the keyword 'seo tips'"
45
+ - "Check if my sitemap and robots.txt are configured correctly"
46
+
47
+ ## Tools
48
+
49
+ ### `run_pagespeed_test`
50
+ Full PageSpeed Insights API response with all Lighthouse data.
51
+
52
+ | Parameter | Type | Default | Description |
53
+ |-----------|------|---------|-------------|
54
+ | `url` | string | required | URL to test |
55
+ | `strategy` | string | `mobile` | `mobile` or `desktop` |
56
+ | `category` | array | `['performance']` | Any of: `performance`, `accessibility`, `best-practices`, `pwa`, `seo` |
57
+
58
+ ### `technical_seo_audit`
59
+ Analyzes technical SEO elements.
60
+
61
+ **Checks:** Title tag, meta description, canonical URL, hreflang tags, Open Graph, Twitter Cards, JSON-LD structured data, viewport, language attribute.
62
+
63
+ ### `sitemap_robots_audit`
64
+ Validates sitemap and robots.txt.
65
+
66
+ **Checks:** robots.txt existence/content, sitemap references, sitemap.xml format, URL count.
67
+
68
+ ### `onpage_seo_audit`
69
+ Analyzes on-page SEO factors.
70
+
71
+ | Parameter | Type | Description |
72
+ |-----------|------|-------------|
73
+ | `url` | string | URL to analyze |
74
+ | `targetKeyword` | string | Optional keyword to check optimization |
75
+
76
+ **Checks:** Heading structure (H1-H6), word count, internal/external links, image alt text, keyword placement/density.
77
+
78
+ ### `security_headers_audit`
79
+ Checks security headers.
80
+
81
+ **Checks:** HTTPS, Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy.
82
+
83
+ ### `performance_audit`
84
+ Summarized performance report.
85
+
86
+ **Returns:** Performance/Accessibility/SEO scores, Core Web Vitals (FCP, LCP, TBT, CLS), top optimization opportunities, overall grade (A-F).
87
+
88
+ ### `mobile_audit`
89
+ Mobile-friendliness check.
90
+
91
+ **Checks:** Viewport configuration, tap target sizes, font sizes, content width, responsive images, PWA elements (manifest, theme-color, touch icons).
92
+
93
+ ## Using Your Own API Key (Optional)
94
+
95
+ The server works out of the box. For higher rate limits, add your own key:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "pagespeed": {
101
+ "command": "npx",
102
+ "args": ["-y", "pagespeed-mcp-server"],
103
+ "env": {
104
+ "PAGESPEED_API_KEY": "your-api-key-here"
105
+ }
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ Get a free API key at [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
112
+
113
+ ## License
114
+
115
+ MIT
116
+
117
+ ## About Me
118
+
119
+ I'm **Sofian Bettayeb**.
120
+
121
+ By day, I'm a martech consultant, working with billion-dollar brands like Rolex and Helsana. By night, I build tools like **AI SEO Copilot** (15k+ installs), **AEO Copilot**, and blueprints like **Webflow SEO Checklist** (1k+ downloads) to help my Webflow friends make money with SEO and AEO.
122
+
123
+ In between, I ride my bikes and play with my kids in Bern, Switzerland.
124
+
125
+ [GitHub](https://github.com/sofianbettayeb)
package/dist/index.js ADDED
@@ -0,0 +1,901 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { z } from "zod";
6
+ import { zodToJsonSchema } from "zod-to-json-schema";
7
+ import fetch from 'node-fetch';
8
+ import * as cheerio from 'cheerio';
9
+ // ============================================
10
+ // API KEY CONFIGURATION
11
+ // ============================================
12
+ // Default API key for PageSpeed Insights - works out of the box
13
+ // Users can override with PAGESPEED_API_KEY env var for higher quotas
14
+ const DEFAULT_API_KEY = "AIzaSyCy_h1_YPQUKGMvLusmCJDZ_q9uoBbYnuU";
15
+ function getApiKey() {
16
+ // Priority: 1) Environment variable, 2) Default key
17
+ return process.env.PAGESPEED_API_KEY || DEFAULT_API_KEY;
18
+ }
19
+ // ============================================
20
+ // SCHEMA DEFINITIONS
21
+ // ============================================
22
+ const UrlSchema = z.object({
23
+ url: z.string().url()
24
+ });
25
+ const RunPageSpeedTestSchema = z.object({
26
+ url: z.string().url(),
27
+ strategy: z.enum(['mobile', 'desktop']).default('mobile'),
28
+ category: z.array(z.enum([
29
+ 'accessibility',
30
+ 'best-practices',
31
+ 'performance',
32
+ 'pwa',
33
+ 'seo'
34
+ ])).default(['performance']),
35
+ locale: z.string().default('en'),
36
+ apiKey: z.string().optional()
37
+ });
38
+ const TechnicalSeoAuditSchema = z.object({
39
+ url: z.string().url()
40
+ });
41
+ const SitemapRobotsSchema = z.object({
42
+ url: z.string().url()
43
+ });
44
+ const OnPageSeoSchema = z.object({
45
+ url: z.string().url(),
46
+ targetKeyword: z.string().optional()
47
+ });
48
+ const SecurityHeadersSchema = z.object({
49
+ url: z.string().url()
50
+ });
51
+ const PerformanceAuditSchema = z.object({
52
+ url: z.string().url(),
53
+ strategy: z.enum(['mobile', 'desktop']).default('mobile')
54
+ });
55
+ const MobileAuditSchema = z.object({
56
+ url: z.string().url()
57
+ });
58
+ // ============================================
59
+ // SERVER SETUP
60
+ // ============================================
61
+ const server = new Server({
62
+ name: "seo-audit-server",
63
+ version: "2.1.0",
64
+ }, {
65
+ capabilities: {
66
+ tools: {},
67
+ },
68
+ });
69
+ // ============================================
70
+ // HELPER FUNCTIONS
71
+ // ============================================
72
+ async function fetchPage(url) {
73
+ const response = await fetch(url, {
74
+ headers: {
75
+ 'User-Agent': 'Mozilla/5.0 (compatible; SEOAuditBot/1.0)'
76
+ },
77
+ redirect: 'follow'
78
+ });
79
+ const html = await response.text();
80
+ const headers = {};
81
+ response.headers.forEach((value, key) => {
82
+ headers[key] = value;
83
+ });
84
+ return { html, response, headers };
85
+ }
86
+ function getBaseUrl(url) {
87
+ const parsed = new URL(url);
88
+ return `${parsed.protocol}//${parsed.host}`;
89
+ }
90
+ // ============================================
91
+ // TOOL IMPLEMENTATIONS
92
+ // ============================================
93
+ // 1. PageSpeed Test (existing, with env var fix)
94
+ async function runPageSpeedTest(params) {
95
+ const { url, strategy, category, locale, apiKey: passedApiKey } = params;
96
+ const apiKey = passedApiKey || getApiKey();
97
+ const categoriesParam = category.join('&category=');
98
+ const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=${strategy}&category=${categoriesParam}&locale=${locale}${apiKey ? `&key=${apiKey}` : ''}`;
99
+ const response = await fetch(apiUrl);
100
+ if (!response.ok) {
101
+ throw new Error(`PageSpeed API error: ${response.statusText}`);
102
+ }
103
+ const data = await response.json();
104
+ return data;
105
+ }
106
+ // 2. Technical SEO Audit
107
+ async function runTechnicalSeoAudit(params) {
108
+ const { url } = params;
109
+ const { html, headers } = await fetchPage(url);
110
+ const $ = cheerio.load(html);
111
+ // Meta tags analysis
112
+ const metaTags = {
113
+ title: $('title').text() || null,
114
+ titleLength: $('title').text()?.length || 0,
115
+ metaDescription: $('meta[name="description"]').attr('content') || null,
116
+ metaDescriptionLength: $('meta[name="description"]').attr('content')?.length || 0,
117
+ metaKeywords: $('meta[name="keywords"]').attr('content') || null,
118
+ metaRobots: $('meta[name="robots"]').attr('content') || null,
119
+ metaViewport: $('meta[name="viewport"]').attr('content') || null,
120
+ ogTitle: $('meta[property="og:title"]').attr('content') || null,
121
+ ogDescription: $('meta[property="og:description"]').attr('content') || null,
122
+ ogImage: $('meta[property="og:image"]').attr('content') || null,
123
+ ogUrl: $('meta[property="og:url"]').attr('content') || null,
124
+ ogType: $('meta[property="og:type"]').attr('content') || null,
125
+ twitterCard: $('meta[name="twitter:card"]').attr('content') || null,
126
+ twitterTitle: $('meta[name="twitter:title"]').attr('content') || null,
127
+ twitterDescription: $('meta[name="twitter:description"]').attr('content') || null,
128
+ twitterImage: $('meta[name="twitter:image"]').attr('content') || null,
129
+ };
130
+ // Canonical URL
131
+ const canonical = $('link[rel="canonical"]').attr('href') || null;
132
+ // Hreflang tags
133
+ const hreflangTags = [];
134
+ $('link[rel="alternate"][hreflang]').each((_, el) => {
135
+ hreflangTags.push({
136
+ lang: $(el).attr('hreflang') || '',
137
+ href: $(el).attr('href') || ''
138
+ });
139
+ });
140
+ // Structured data (JSON-LD)
141
+ const structuredData = [];
142
+ $('script[type="application/ld+json"]').each((_, el) => {
143
+ try {
144
+ const json = JSON.parse($(el).html() || '');
145
+ structuredData.push(json);
146
+ }
147
+ catch (e) {
148
+ structuredData.push({ error: 'Invalid JSON-LD', raw: $(el).html()?.substring(0, 200) });
149
+ }
150
+ });
151
+ // Language
152
+ const htmlLang = $('html').attr('lang') || null;
153
+ // Charset
154
+ const charset = $('meta[charset]').attr('charset') ||
155
+ $('meta[http-equiv="Content-Type"]').attr('content') || null;
156
+ // Issues and recommendations
157
+ const issues = [];
158
+ const recommendations = [];
159
+ // Title checks
160
+ if (!metaTags.title) {
161
+ issues.push('Missing title tag');
162
+ }
163
+ else if (metaTags.titleLength < 30) {
164
+ recommendations.push(`Title is too short (${metaTags.titleLength} chars). Aim for 50-60 characters.`);
165
+ }
166
+ else if (metaTags.titleLength > 60) {
167
+ recommendations.push(`Title is too long (${metaTags.titleLength} chars). Keep under 60 characters.`);
168
+ }
169
+ // Meta description checks
170
+ if (!metaTags.metaDescription) {
171
+ issues.push('Missing meta description');
172
+ }
173
+ else if (metaTags.metaDescriptionLength < 120) {
174
+ recommendations.push(`Meta description is short (${metaTags.metaDescriptionLength} chars). Aim for 150-160 characters.`);
175
+ }
176
+ else if (metaTags.metaDescriptionLength > 160) {
177
+ recommendations.push(`Meta description is too long (${metaTags.metaDescriptionLength} chars). Keep under 160 characters.`);
178
+ }
179
+ // Canonical check
180
+ if (!canonical) {
181
+ recommendations.push('Consider adding a canonical URL to prevent duplicate content issues.');
182
+ }
183
+ // Viewport check
184
+ if (!metaTags.metaViewport) {
185
+ issues.push('Missing viewport meta tag - site may not be mobile-friendly');
186
+ }
187
+ // Open Graph checks
188
+ if (!metaTags.ogTitle || !metaTags.ogDescription || !metaTags.ogImage) {
189
+ recommendations.push('Missing Open Graph tags - social sharing may not display properly.');
190
+ }
191
+ // Language check
192
+ if (!htmlLang) {
193
+ recommendations.push('Missing lang attribute on <html> tag. Important for accessibility and SEO.');
194
+ }
195
+ // Structured data check
196
+ if (structuredData.length === 0) {
197
+ recommendations.push('No structured data found. Consider adding JSON-LD schema markup.');
198
+ }
199
+ return {
200
+ url,
201
+ metaTags,
202
+ canonical,
203
+ hreflangTags,
204
+ structuredData,
205
+ htmlLang,
206
+ charset,
207
+ issues,
208
+ recommendations,
209
+ score: {
210
+ total: 100 - (issues.length * 15) - (recommendations.length * 5),
211
+ issuesCount: issues.length,
212
+ recommendationsCount: recommendations.length
213
+ }
214
+ };
215
+ }
216
+ // 3. Sitemap & Robots.txt Validation
217
+ async function runSitemapRobotsAudit(params) {
218
+ const { url } = params;
219
+ const baseUrl = getBaseUrl(url);
220
+ const results = {
221
+ url,
222
+ robotsTxt: {
223
+ exists: false,
224
+ content: null,
225
+ issues: [],
226
+ sitemapReferences: []
227
+ },
228
+ sitemap: {
229
+ exists: false,
230
+ url: null,
231
+ urlCount: 0,
232
+ issues: [],
233
+ sampleUrls: []
234
+ }
235
+ };
236
+ // Check robots.txt
237
+ try {
238
+ const robotsUrl = `${baseUrl}/robots.txt`;
239
+ const robotsResponse = await fetch(robotsUrl);
240
+ if (robotsResponse.ok) {
241
+ results.robotsTxt.exists = true;
242
+ results.robotsTxt.content = await robotsResponse.text();
243
+ // Parse robots.txt
244
+ const lines = results.robotsTxt.content.split('\n');
245
+ for (const line of lines) {
246
+ const trimmed = line.trim().toLowerCase();
247
+ if (trimmed.startsWith('sitemap:')) {
248
+ results.robotsTxt.sitemapReferences.push(line.split(':').slice(1).join(':').trim());
249
+ }
250
+ if (trimmed === 'disallow: /') {
251
+ results.robotsTxt.issues.push('WARNING: Entire site is blocked from crawling (Disallow: /)');
252
+ }
253
+ }
254
+ }
255
+ else {
256
+ results.robotsTxt.issues.push('robots.txt not found or not accessible');
257
+ }
258
+ }
259
+ catch (error) {
260
+ results.robotsTxt.issues.push(`Error fetching robots.txt: ${error}`);
261
+ }
262
+ // Check sitemap
263
+ const sitemapUrls = [
264
+ ...results.robotsTxt.sitemapReferences,
265
+ `${baseUrl}/sitemap.xml`,
266
+ `${baseUrl}/sitemap_index.xml`,
267
+ `${baseUrl}/sitemap/sitemap.xml`
268
+ ];
269
+ for (const sitemapUrl of [...new Set(sitemapUrls)]) {
270
+ try {
271
+ const sitemapResponse = await fetch(sitemapUrl);
272
+ if (sitemapResponse.ok) {
273
+ results.sitemap.exists = true;
274
+ results.sitemap.url = sitemapUrl;
275
+ const sitemapContent = await sitemapResponse.text();
276
+ // Parse sitemap
277
+ const $ = cheerio.load(sitemapContent, { xmlMode: true });
278
+ // Check if it's a sitemap index
279
+ const sitemapIndex = $('sitemapindex sitemap loc');
280
+ if (sitemapIndex.length > 0) {
281
+ results.sitemap.type = 'sitemap_index';
282
+ results.sitemap.childSitemaps = [];
283
+ sitemapIndex.each((_, el) => {
284
+ results.sitemap.childSitemaps.push($(el).text());
285
+ });
286
+ results.sitemap.urlCount = sitemapIndex.length;
287
+ }
288
+ else {
289
+ // Regular sitemap
290
+ results.sitemap.type = 'sitemap';
291
+ const urls = $('urlset url loc');
292
+ results.sitemap.urlCount = urls.length;
293
+ urls.slice(0, 5).each((_, el) => {
294
+ results.sitemap.sampleUrls.push($(el).text());
295
+ });
296
+ }
297
+ // Validation
298
+ if (results.sitemap.urlCount === 0) {
299
+ results.sitemap.issues.push('Sitemap appears to be empty');
300
+ }
301
+ if (results.sitemap.urlCount > 50000) {
302
+ results.sitemap.issues.push('Sitemap exceeds 50,000 URL limit');
303
+ }
304
+ break; // Found a valid sitemap
305
+ }
306
+ }
307
+ catch (error) {
308
+ // Continue to next sitemap URL
309
+ }
310
+ }
311
+ if (!results.sitemap.exists) {
312
+ results.sitemap.issues.push('No sitemap found. Consider creating one for better indexing.');
313
+ }
314
+ return results;
315
+ }
316
+ // 4. On-Page SEO Analysis
317
+ async function runOnPageSeoAudit(params) {
318
+ const { url, targetKeyword } = params;
319
+ const { html } = await fetchPage(url);
320
+ const $ = cheerio.load(html);
321
+ // Remove script and style tags for content analysis
322
+ $('script, style, noscript').remove();
323
+ // Heading structure
324
+ const headings = {
325
+ h1: [],
326
+ h2: [],
327
+ h3: [],
328
+ h4: [],
329
+ h5: [],
330
+ h6: []
331
+ };
332
+ for (let i = 1; i <= 6; i++) {
333
+ $(`h${i}`).each((_, el) => {
334
+ const text = $(el).text().trim();
335
+ if (text)
336
+ headings[`h${i}`].push(text);
337
+ });
338
+ }
339
+ // Content analysis
340
+ const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
341
+ const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
342
+ // Links analysis
343
+ const internalLinks = [];
344
+ const externalLinks = [];
345
+ const baseHost = new URL(url).host;
346
+ $('a[href]').each((_, el) => {
347
+ const href = $(el).attr('href');
348
+ if (!href)
349
+ return;
350
+ try {
351
+ const linkUrl = new URL(href, url);
352
+ if (linkUrl.host === baseHost) {
353
+ internalLinks.push(href);
354
+ }
355
+ else if (linkUrl.protocol.startsWith('http')) {
356
+ externalLinks.push(href);
357
+ }
358
+ }
359
+ catch {
360
+ // Invalid URL, skip
361
+ }
362
+ });
363
+ // Images analysis
364
+ const images = [];
365
+ $('img').each((_, el) => {
366
+ const src = $(el).attr('src') || $(el).attr('data-src') || '';
367
+ const alt = $(el).attr('alt');
368
+ images.push({
369
+ src: src.substring(0, 100),
370
+ alt: alt || null,
371
+ hasAlt: alt !== undefined && alt !== ''
372
+ });
373
+ });
374
+ const imagesWithoutAlt = images.filter(img => !img.hasAlt).length;
375
+ // Keyword analysis (if provided)
376
+ let keywordAnalysis = null;
377
+ if (targetKeyword) {
378
+ const keyword = targetKeyword.toLowerCase();
379
+ const titleText = $('title').text().toLowerCase();
380
+ const metaDesc = ($('meta[name="description"]').attr('content') || '').toLowerCase();
381
+ const h1Text = headings.h1.join(' ').toLowerCase();
382
+ const bodyLower = bodyText.toLowerCase();
383
+ // Count keyword occurrences
384
+ const keywordRegex = new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
385
+ const keywordCount = (bodyText.match(keywordRegex) || []).length;
386
+ const keywordDensity = wordCount > 0 ? ((keywordCount / wordCount) * 100).toFixed(2) : '0';
387
+ keywordAnalysis = {
388
+ keyword: targetKeyword,
389
+ inTitle: titleText.includes(keyword),
390
+ inMetaDescription: metaDesc.includes(keyword),
391
+ inH1: h1Text.includes(keyword),
392
+ inFirst100Words: bodyLower.substring(0, bodyLower.split(/\s+/).slice(0, 100).join(' ').length).includes(keyword),
393
+ totalOccurrences: keywordCount,
394
+ density: `${keywordDensity}%`
395
+ };
396
+ }
397
+ // Issues and recommendations
398
+ const issues = [];
399
+ const recommendations = [];
400
+ // H1 checks
401
+ if (headings.h1.length === 0) {
402
+ issues.push('Missing H1 tag');
403
+ }
404
+ else if (headings.h1.length > 1) {
405
+ recommendations.push(`Multiple H1 tags found (${headings.h1.length}). Consider using only one H1.`);
406
+ }
407
+ // Content length
408
+ if (wordCount < 300) {
409
+ recommendations.push(`Content is thin (${wordCount} words). Aim for at least 300 words for better SEO.`);
410
+ }
411
+ // Images
412
+ if (imagesWithoutAlt > 0) {
413
+ issues.push(`${imagesWithoutAlt} image(s) missing alt text`);
414
+ }
415
+ // Internal linking
416
+ if (internalLinks.length < 3) {
417
+ recommendations.push('Few internal links. Add more internal links for better site structure.');
418
+ }
419
+ // Keyword optimization
420
+ if (keywordAnalysis) {
421
+ if (!keywordAnalysis.inTitle) {
422
+ recommendations.push(`Target keyword "${targetKeyword}" not found in title`);
423
+ }
424
+ if (!keywordAnalysis.inH1) {
425
+ recommendations.push(`Target keyword "${targetKeyword}" not found in H1`);
426
+ }
427
+ if (!keywordAnalysis.inMetaDescription) {
428
+ recommendations.push(`Target keyword "${targetKeyword}" not found in meta description`);
429
+ }
430
+ }
431
+ return {
432
+ url,
433
+ headings: {
434
+ h1: headings.h1,
435
+ h2: headings.h2,
436
+ h3: headings.h3.slice(0, 10),
437
+ structure: `H1: ${headings.h1.length}, H2: ${headings.h2.length}, H3: ${headings.h3.length}, H4: ${headings.h4.length}`
438
+ },
439
+ content: {
440
+ wordCount,
441
+ readingTime: `${Math.ceil(wordCount / 200)} min`
442
+ },
443
+ links: {
444
+ internal: internalLinks.length,
445
+ external: externalLinks.length,
446
+ sampleInternal: internalLinks.slice(0, 5),
447
+ sampleExternal: externalLinks.slice(0, 5)
448
+ },
449
+ images: {
450
+ total: images.length,
451
+ withoutAlt: imagesWithoutAlt,
452
+ altCoverage: images.length > 0 ? `${((1 - imagesWithoutAlt / images.length) * 100).toFixed(0)}%` : 'N/A'
453
+ },
454
+ keywordAnalysis,
455
+ issues,
456
+ recommendations
457
+ };
458
+ }
459
+ // 5. Security & Headers Check
460
+ async function runSecurityHeadersAudit(params) {
461
+ const { url } = params;
462
+ const { headers, response } = await fetchPage(url);
463
+ const parsedUrl = new URL(url);
464
+ const isHttps = parsedUrl.protocol === 'https:';
465
+ // Security headers to check
466
+ const securityHeaders = {
467
+ 'strict-transport-security': {
468
+ present: false,
469
+ value: null,
470
+ description: 'HSTS - Forces HTTPS connections'
471
+ },
472
+ 'content-security-policy': {
473
+ present: false,
474
+ value: null,
475
+ description: 'CSP - Prevents XSS attacks'
476
+ },
477
+ 'x-content-type-options': {
478
+ present: false,
479
+ value: null,
480
+ description: 'Prevents MIME type sniffing'
481
+ },
482
+ 'x-frame-options': {
483
+ present: false,
484
+ value: null,
485
+ description: 'Prevents clickjacking'
486
+ },
487
+ 'x-xss-protection': {
488
+ present: false,
489
+ value: null,
490
+ description: 'XSS filter (legacy)'
491
+ },
492
+ 'referrer-policy': {
493
+ present: false,
494
+ value: null,
495
+ description: 'Controls referrer information'
496
+ },
497
+ 'permissions-policy': {
498
+ present: false,
499
+ value: null,
500
+ description: 'Controls browser features'
501
+ }
502
+ };
503
+ // Check each security header
504
+ for (const [header, info] of Object.entries(securityHeaders)) {
505
+ const value = headers[header];
506
+ if (value) {
507
+ info.present = true;
508
+ info.value = value;
509
+ }
510
+ }
511
+ // Issues and recommendations
512
+ const issues = [];
513
+ const recommendations = [];
514
+ if (!isHttps) {
515
+ issues.push('Site is not using HTTPS - this is critical for security and SEO');
516
+ }
517
+ if (!securityHeaders['strict-transport-security'].present && isHttps) {
518
+ recommendations.push('Add Strict-Transport-Security header to enforce HTTPS');
519
+ }
520
+ if (!securityHeaders['content-security-policy'].present) {
521
+ recommendations.push('Add Content-Security-Policy header to prevent XSS attacks');
522
+ }
523
+ if (!securityHeaders['x-content-type-options'].present) {
524
+ recommendations.push('Add X-Content-Type-Options: nosniff header');
525
+ }
526
+ if (!securityHeaders['x-frame-options'].present) {
527
+ recommendations.push('Add X-Frame-Options header to prevent clickjacking');
528
+ }
529
+ // Calculate security score
530
+ const headersPresent = Object.values(securityHeaders).filter(h => h.present).length;
531
+ const totalHeaders = Object.keys(securityHeaders).length;
532
+ const httpsScore = isHttps ? 30 : 0;
533
+ const headersScore = (headersPresent / totalHeaders) * 70;
534
+ return {
535
+ url,
536
+ https: {
537
+ enabled: isHttps,
538
+ protocol: parsedUrl.protocol
539
+ },
540
+ securityHeaders,
541
+ allHeaders: headers,
542
+ issues,
543
+ recommendations,
544
+ score: {
545
+ total: Math.round(httpsScore + headersScore),
546
+ httpsEnabled: isHttps,
547
+ headersPresent,
548
+ headersTotal: totalHeaders
549
+ }
550
+ };
551
+ }
552
+ // 6. Performance Audit Summary (simplified PageSpeed results)
553
+ async function runPerformanceAudit(params) {
554
+ const { url, strategy } = params;
555
+ const apiKey = getApiKey();
556
+ const categories = ['performance', 'accessibility', 'best-practices', 'seo'];
557
+ const categoriesParam = categories.join('&category=');
558
+ const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=${strategy}&category=${categoriesParam}${apiKey ? `&key=${apiKey}` : ''}`;
559
+ const response = await fetch(apiUrl);
560
+ if (!response.ok) {
561
+ throw new Error(`PageSpeed API error: ${response.statusText}`);
562
+ }
563
+ const data = await response.json();
564
+ const lighthouse = data.lighthouseResult;
565
+ // Extract key metrics
566
+ const metrics = {
567
+ firstContentfulPaint: lighthouse?.audits?.['first-contentful-paint']?.displayValue || 'N/A',
568
+ largestContentfulPaint: lighthouse?.audits?.['largest-contentful-paint']?.displayValue || 'N/A',
569
+ totalBlockingTime: lighthouse?.audits?.['total-blocking-time']?.displayValue || 'N/A',
570
+ cumulativeLayoutShift: lighthouse?.audits?.['cumulative-layout-shift']?.displayValue || 'N/A',
571
+ speedIndex: lighthouse?.audits?.['speed-index']?.displayValue || 'N/A',
572
+ timeToInteractive: lighthouse?.audits?.['interactive']?.displayValue || 'N/A'
573
+ };
574
+ // Extract scores
575
+ const scores = {
576
+ performance: Math.round((lighthouse?.categories?.performance?.score || 0) * 100),
577
+ accessibility: Math.round((lighthouse?.categories?.accessibility?.score || 0) * 100),
578
+ bestPractices: Math.round((lighthouse?.categories?.['best-practices']?.score || 0) * 100),
579
+ seo: Math.round((lighthouse?.categories?.seo?.score || 0) * 100)
580
+ };
581
+ // Extract top opportunities
582
+ const opportunities = [];
583
+ const audits = lighthouse?.audits || {};
584
+ for (const [key, audit] of Object.entries(audits)) {
585
+ if (audit?.details?.overallSavingsMs > 0) {
586
+ opportunities.push({
587
+ title: audit.title,
588
+ savings: `${(audit.details.overallSavingsMs / 1000).toFixed(1)}s`
589
+ });
590
+ }
591
+ }
592
+ opportunities.sort((a, b) => parseFloat(b.savings) - parseFloat(a.savings));
593
+ // Issues based on scores
594
+ const issues = [];
595
+ if (scores.performance < 50)
596
+ issues.push('Critical: Performance score is poor');
597
+ if (scores.accessibility < 90)
598
+ issues.push('Accessibility needs improvement');
599
+ if (scores.seo < 90)
600
+ issues.push('SEO score needs improvement');
601
+ return {
602
+ url,
603
+ strategy,
604
+ scores,
605
+ coreWebVitals: metrics,
606
+ topOpportunities: opportunities.slice(0, 5),
607
+ issues,
608
+ overallGrade: scores.performance >= 90 ? 'A' :
609
+ scores.performance >= 70 ? 'B' :
610
+ scores.performance >= 50 ? 'C' :
611
+ scores.performance >= 30 ? 'D' : 'F'
612
+ };
613
+ }
614
+ // 7. Mobile-Friendliness Audit
615
+ async function runMobileAudit(params) {
616
+ const { url } = params;
617
+ const apiKey = getApiKey();
618
+ // Fetch page HTML for static checks
619
+ const { html } = await fetchPage(url);
620
+ const $ = cheerio.load(html);
621
+ // Static mobile checks
622
+ const viewport = $('meta[name="viewport"]').attr('content') || null;
623
+ const hasViewport = !!viewport;
624
+ const viewportHasWidthDevice = viewport?.includes('width=device-width') || false;
625
+ const viewportHasInitialScale = viewport?.includes('initial-scale=1') || false;
626
+ // Check for mobile-specific meta tags
627
+ const appleMobileWebAppCapable = $('meta[name="apple-mobile-web-app-capable"]').attr('content') || null;
628
+ const themeColor = $('meta[name="theme-color"]').attr('content') || null;
629
+ const mobileOptimized = $('meta[name="MobileOptimized"]').attr('content') || null;
630
+ const handheldFriendly = $('meta[name="HandheldFriendly"]').attr('content') || null;
631
+ // Check for touch icons
632
+ const appleTouchIcon = $('link[rel="apple-touch-icon"]').attr('href') || null;
633
+ const manifest = $('link[rel="manifest"]').attr('href') || null;
634
+ // Check for responsive images
635
+ const images = $('img');
636
+ let responsiveImages = 0;
637
+ let totalImages = images.length;
638
+ images.each((_, el) => {
639
+ const srcset = $(el).attr('srcset');
640
+ const sizes = $(el).attr('sizes');
641
+ const style = $(el).attr('style') || '';
642
+ const hasMaxWidth = style.includes('max-width') || $(el).attr('class')?.includes('responsive');
643
+ if (srcset || sizes || hasMaxWidth) {
644
+ responsiveImages++;
645
+ }
646
+ });
647
+ // Check for horizontal scroll issues (fixed width elements)
648
+ const potentialOverflowElements = [];
649
+ $('[style*="width"]').each((_, el) => {
650
+ const style = $(el).attr('style') || '';
651
+ const widthMatch = style.match(/width:\s*(\d+)px/);
652
+ if (widthMatch && parseInt(widthMatch[1]) > 400) {
653
+ potentialOverflowElements.push($(el).prop('tagName') || 'unknown');
654
+ }
655
+ });
656
+ // Get mobile PageSpeed data for tap targets, font sizes, etc.
657
+ let mobilePageSpeed = null;
658
+ let tapTargets = null;
659
+ let fontSizes = null;
660
+ let contentWidth = null;
661
+ try {
662
+ const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile&category=accessibility&category=seo${apiKey ? `&key=${apiKey}` : ''}`;
663
+ const response = await fetch(apiUrl);
664
+ if (response.ok) {
665
+ mobilePageSpeed = await response.json();
666
+ const audits = mobilePageSpeed?.lighthouseResult?.audits || {};
667
+ // Tap targets
668
+ tapTargets = {
669
+ score: audits['tap-targets']?.score,
670
+ displayValue: audits['tap-targets']?.displayValue || 'N/A',
671
+ description: audits['tap-targets']?.description || '',
672
+ failures: audits['tap-targets']?.details?.items?.slice(0, 5).map((item) => ({
673
+ target: item.tapTarget?.snippet?.substring(0, 100),
674
+ size: item.size,
675
+ overlappingTarget: item.overlappingTarget?.snippet?.substring(0, 50)
676
+ })) || []
677
+ };
678
+ // Font sizes
679
+ fontSizes = {
680
+ score: audits['font-size']?.score,
681
+ displayValue: audits['font-size']?.displayValue || 'N/A',
682
+ description: audits['font-size']?.description || ''
683
+ };
684
+ // Content width
685
+ contentWidth = {
686
+ score: audits['content-width']?.score,
687
+ description: audits['content-width']?.description || ''
688
+ };
689
+ }
690
+ }
691
+ catch (error) {
692
+ // PageSpeed API failed, continue with static checks only
693
+ }
694
+ // Build issues and recommendations
695
+ const issues = [];
696
+ const recommendations = [];
697
+ // Viewport issues
698
+ if (!hasViewport) {
699
+ issues.push('CRITICAL: Missing viewport meta tag - page will not render correctly on mobile');
700
+ }
701
+ else {
702
+ if (!viewportHasWidthDevice) {
703
+ issues.push('Viewport missing width=device-width');
704
+ }
705
+ if (!viewportHasInitialScale) {
706
+ recommendations.push('Consider adding initial-scale=1 to viewport meta tag');
707
+ }
708
+ }
709
+ // Tap targets
710
+ if (tapTargets?.score !== null && tapTargets.score < 1) {
711
+ issues.push(`Tap targets too small: ${tapTargets.displayValue}`);
712
+ }
713
+ // Font sizes
714
+ if (fontSizes?.score !== null && fontSizes.score < 1) {
715
+ issues.push(`Font sizes too small for mobile: ${fontSizes.displayValue}`);
716
+ }
717
+ // Content width
718
+ if (contentWidth?.score !== null && contentWidth.score < 1) {
719
+ issues.push('Content wider than screen - causes horizontal scrolling');
720
+ }
721
+ // Responsive images
722
+ if (totalImages > 0 && responsiveImages / totalImages < 0.5) {
723
+ recommendations.push(`Only ${Math.round((responsiveImages / totalImages) * 100)}% of images use responsive techniques (srcset/sizes)`);
724
+ }
725
+ // PWA recommendations
726
+ if (!manifest) {
727
+ recommendations.push('No web app manifest found - consider adding for PWA support');
728
+ }
729
+ if (!themeColor) {
730
+ recommendations.push('No theme-color meta tag - browser UI won\'t match your brand on mobile');
731
+ }
732
+ if (!appleTouchIcon) {
733
+ recommendations.push('No Apple touch icon - iOS home screen icon will be a screenshot');
734
+ }
735
+ // Calculate score
736
+ let score = 100;
737
+ score -= issues.length * 20;
738
+ score -= recommendations.length * 5;
739
+ score = Math.max(0, score);
740
+ return {
741
+ url,
742
+ viewport: {
743
+ present: hasViewport,
744
+ content: viewport,
745
+ hasWidthDevice: viewportHasWidthDevice,
746
+ hasInitialScale: viewportHasInitialScale
747
+ },
748
+ mobileMeta: {
749
+ appleMobileWebAppCapable,
750
+ themeColor,
751
+ mobileOptimized,
752
+ handheldFriendly,
753
+ appleTouchIcon,
754
+ manifest
755
+ },
756
+ responsiveImages: {
757
+ total: totalImages,
758
+ responsive: responsiveImages,
759
+ percentage: totalImages > 0 ? `${Math.round((responsiveImages / totalImages) * 100)}%` : 'N/A'
760
+ },
761
+ tapTargets,
762
+ fontSizes,
763
+ contentWidth,
764
+ potentialOverflowElements: potentialOverflowElements.slice(0, 5),
765
+ issues,
766
+ recommendations,
767
+ score: {
768
+ total: score,
769
+ grade: score >= 90 ? 'A' : score >= 70 ? 'B' : score >= 50 ? 'C' : score >= 30 ? 'D' : 'F'
770
+ }
771
+ };
772
+ }
773
+ // ============================================
774
+ // TOOL HANDLERS
775
+ // ============================================
776
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
777
+ return {
778
+ tools: [
779
+ {
780
+ name: "run_pagespeed_test",
781
+ description: "Run a full PageSpeed Insights test on a URL. Returns detailed performance, accessibility, SEO, and best practices data.",
782
+ inputSchema: zodToJsonSchema(RunPageSpeedTestSchema),
783
+ },
784
+ {
785
+ name: "technical_seo_audit",
786
+ description: "Audit technical SEO elements: meta tags, canonical URLs, hreflang, Open Graph, Twitter Cards, structured data (JSON-LD), and more.",
787
+ inputSchema: zodToJsonSchema(TechnicalSeoAuditSchema),
788
+ },
789
+ {
790
+ name: "sitemap_robots_audit",
791
+ description: "Validate sitemap.xml and robots.txt files. Checks existence, format, URL count, and common issues.",
792
+ inputSchema: zodToJsonSchema(SitemapRobotsSchema),
793
+ },
794
+ {
795
+ name: "onpage_seo_audit",
796
+ description: "Analyze on-page SEO factors: heading structure, content length, internal/external links, images alt text, and optional keyword optimization analysis.",
797
+ inputSchema: zodToJsonSchema(OnPageSeoSchema),
798
+ },
799
+ {
800
+ name: "security_headers_audit",
801
+ description: "Check HTTPS status and security headers (HSTS, CSP, X-Frame-Options, etc.). Important for both security and SEO.",
802
+ inputSchema: zodToJsonSchema(SecurityHeadersSchema),
803
+ },
804
+ {
805
+ name: "performance_audit",
806
+ description: "Get a summarized performance audit with Core Web Vitals, scores for performance/accessibility/SEO, and top optimization opportunities.",
807
+ inputSchema: zodToJsonSchema(PerformanceAuditSchema),
808
+ },
809
+ {
810
+ name: "mobile_audit",
811
+ description: "Comprehensive mobile-friendliness audit: viewport configuration, tap target sizes, font sizes, content width, responsive images, PWA readiness, and mobile-specific meta tags.",
812
+ inputSchema: zodToJsonSchema(MobileAuditSchema),
813
+ }
814
+ ],
815
+ };
816
+ });
817
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
818
+ try {
819
+ const { name, arguments: args } = request.params;
820
+ let result;
821
+ switch (name) {
822
+ case "run_pagespeed_test": {
823
+ const parsed = RunPageSpeedTestSchema.safeParse(args);
824
+ if (!parsed.success)
825
+ throw new Error(`Invalid arguments: ${parsed.error}`);
826
+ result = await runPageSpeedTest(parsed.data);
827
+ break;
828
+ }
829
+ case "technical_seo_audit": {
830
+ const parsed = TechnicalSeoAuditSchema.safeParse(args);
831
+ if (!parsed.success)
832
+ throw new Error(`Invalid arguments: ${parsed.error}`);
833
+ result = await runTechnicalSeoAudit(parsed.data);
834
+ break;
835
+ }
836
+ case "sitemap_robots_audit": {
837
+ const parsed = SitemapRobotsSchema.safeParse(args);
838
+ if (!parsed.success)
839
+ throw new Error(`Invalid arguments: ${parsed.error}`);
840
+ result = await runSitemapRobotsAudit(parsed.data);
841
+ break;
842
+ }
843
+ case "onpage_seo_audit": {
844
+ const parsed = OnPageSeoSchema.safeParse(args);
845
+ if (!parsed.success)
846
+ throw new Error(`Invalid arguments: ${parsed.error}`);
847
+ result = await runOnPageSeoAudit(parsed.data);
848
+ break;
849
+ }
850
+ case "security_headers_audit": {
851
+ const parsed = SecurityHeadersSchema.safeParse(args);
852
+ if (!parsed.success)
853
+ throw new Error(`Invalid arguments: ${parsed.error}`);
854
+ result = await runSecurityHeadersAudit(parsed.data);
855
+ break;
856
+ }
857
+ case "performance_audit": {
858
+ const parsed = PerformanceAuditSchema.safeParse(args);
859
+ if (!parsed.success)
860
+ throw new Error(`Invalid arguments: ${parsed.error}`);
861
+ result = await runPerformanceAudit(parsed.data);
862
+ break;
863
+ }
864
+ case "mobile_audit": {
865
+ const parsed = MobileAuditSchema.safeParse(args);
866
+ if (!parsed.success)
867
+ throw new Error(`Invalid arguments: ${parsed.error}`);
868
+ result = await runMobileAudit(parsed.data);
869
+ break;
870
+ }
871
+ default:
872
+ throw new Error(`Unknown tool: ${name}`);
873
+ }
874
+ return {
875
+ content: [{
876
+ type: "text",
877
+ text: JSON.stringify(result, null, 2)
878
+ }],
879
+ isError: false,
880
+ };
881
+ }
882
+ catch (error) {
883
+ const errorMessage = error instanceof Error ? error.message : String(error);
884
+ return {
885
+ content: [{ type: "text", text: `Error: ${errorMessage}` }],
886
+ isError: true,
887
+ };
888
+ }
889
+ });
890
+ // ============================================
891
+ // START SERVER
892
+ // ============================================
893
+ async function runServer() {
894
+ const transport = new StdioServerTransport();
895
+ await server.connect(transport);
896
+ console.error("SEO Audit Server v2.0 running on stdio");
897
+ }
898
+ runServer().catch((error) => {
899
+ console.error("Fatal error running server:", error);
900
+ process.exit(1);
901
+ });
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "pagespeed-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for PageSpeed & SEO audits. Zero config required - works out of the box.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "pagespeed-mcp-server": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc && chmod +x dist/index.js",
12
+ "dev": "tsx src/index.ts",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "seo",
19
+ "pagespeed",
20
+ "core-web-vitals",
21
+ "technical-seo",
22
+ "sitemap",
23
+ "robots-txt",
24
+ "security-headers",
25
+ "on-page-seo",
26
+ "mobile-friendly",
27
+ "claude",
28
+ "ai"
29
+ ],
30
+ "author": "Sofian Bettayeb <sofianbettayeb@gmail.com>",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.0.0",
34
+ "cheerio": "^1.0.0",
35
+ "node-fetch": "^3.3.2",
36
+ "zod": "^3.23.0",
37
+ "zod-to-json-schema": "^3.23.5"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.0.0",
41
+ "tsx": "^4.19.0",
42
+ "typescript": "^5.6.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/sofianbettayeb/pagespeed-mcp-server.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/sofianbettayeb/pagespeed-mcp-server/issues"
58
+ },
59
+ "homepage": "https://github.com/sofianbettayeb/pagespeed-mcp-server#readme"
60
+ }