Skip to main content
Glama

SEO Inspector & Schema Validator MCP

seo-mcp-server.js14.1 kB
#!/usr/bin/env node import * as cheerio from 'cheerio'; import fs from 'fs/promises'; import path from 'path'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; // Define the SEO tool const SEO_ANALYZER_TOOL = { name: 'analyzeSEO', description: 'Analyzes HTML files for SEO issues and provides recommendations.', inputSchema: { type: 'object', properties: { html: { type: 'string', description: 'HTML content to analyze (optional)', }, directoryPath: { type: 'string', description: 'Path to directory to analyze (optional)', }, }, }, }; // Create the server const server = new Server( { name: 'seo-inspector-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // Handle tool listing requests server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [SEO_ANALYZER_TOOL], })); // Handle tool call requests server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'analyzeSEO') { try { // Handle HTML content analysis if (request.params.arguments.html) { const html = request.params.arguments.html; const analysis = analyzeHtml(html, 'Provided HTML'); return { content: [ { type: 'text', text: formatAnalysisResult(analysis), }, ], }; } // Handle directory analysis else if (request.params.arguments.directoryPath) { const directoryPath = request.params.arguments.directoryPath; console.error(`Analyzing directory: ${directoryPath}`); try { // Check if directory exists try { await fs.access(directoryPath); } catch (error) { return { content: [ { type: 'text', text: `Directory "${directoryPath}" does not exist or is not accessible. Please provide a valid directory path.`, }, ], }; } // Find HTML files const htmlFiles = await findHtmlFiles(directoryPath); if (htmlFiles.length === 0) { // Look for index.html in common locations const commonLocations = [ path.join(directoryPath, 'public', 'index.html'), path.join(directoryPath, 'build', 'index.html'), path.join(directoryPath, 'dist', 'index.html'), path.join(directoryPath, 'index.html'), ]; for (const location of commonLocations) { try { await fs.access(location); htmlFiles.push(location); console.error(`Found index.html at ${location}`); } catch (error) { // File doesn't exist, continue checking } } if (htmlFiles.length === 0) { return { content: [ { type: 'text', text: `No HTML files found in ${directoryPath} or common subdirectories (public, build, dist). If this is a React project, please specify the path to the public or build directory, or provide the path to a specific HTML file.`, }, ], }; } } // Analyze each HTML file const results = []; for (const file of htmlFiles) { try { const content = await fs.readFile(file, 'utf8'); const relativePath = path.relative(directoryPath, file); const analysis = analyzeHtml(content, relativePath); results.push(analysis); } catch (error) { console.error(`Error analyzing ${file}:`, error); } } // Format and return results return { content: [ { type: 'text', text: formatDirectoryAnalysisResults(results, directoryPath), }, ], }; } catch (error) { console.error('Error analyzing directory:', error); return { content: [ { type: 'text', text: `Error analyzing directory: ${error.message}`, }, ], isError: true, }; } } else { return { content: [ { type: 'text', text: 'Please provide either HTML content or a directory path to analyze.', }, ], }; } } catch (error) { console.error('Error in analyzeSEO tool:', error); return { content: [ { type: 'text', text: `Error analyzing SEO: ${error.message}`, }, ], isError: true, }; } } }); // Find HTML files in a directory async function findHtmlFiles(directory) { const htmlFiles = []; async function traverse(dir) { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === 'node_modules' || entry.name === '.git') { continue; } const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await traverse(fullPath); } else if ( entry.name.endsWith('.html') || entry.name.endsWith('.htm') ) { htmlFiles.push(fullPath); } } } catch (error) { console.error(`Error traversing ${dir}:`, error); } } await traverse(directory); return htmlFiles; } // Analyze HTML content function analyzeHtml(html, pageIdentifier) { const $ = cheerio.load(html); const issues = []; const recommendations = []; // Basic SEO checks const title = $('title').text(); const metaDescription = $('meta[name="description"]').attr('content'); const h1Count = $('h1').length; const h2Count = $('h2').length; const h3Count = $('h3').length; // Check for React-specific elements const hasReactRoot = $('#root').length > 0 || $('#app').length > 0 || $('[data-reactroot]').length > 0; // Check title if (!title) { issues.push({ severity: 'high', message: 'Missing page title' }); recommendations.push('Add a descriptive page title'); } else if (title.length > 60) { issues.push({ severity: 'medium', message: `Title length (${title.length} chars) exceeds recommended maximum of 60 characters`, }); recommendations.push('Shorten title to under 60 characters'); } // Check meta description if (!metaDescription) { issues.push({ severity: 'high', message: 'Missing meta description' }); recommendations.push('Add a descriptive meta description'); } else if (metaDescription.length < 50 || metaDescription.length > 160) { issues.push({ severity: 'medium', message: `Meta description length (${metaDescription.length} chars) outside recommended range (50-160)`, }); recommendations.push( 'Adjust meta description to be between 50-160 characters' ); } // Check headings if (h1Count === 0) { issues.push({ severity: 'high', message: 'No H1 heading found' }); recommendations.push('Add an H1 heading to your page'); } else if (h1Count > 1) { issues.push({ severity: 'medium', message: `Multiple H1 headings found (${h1Count})`, }); recommendations.push('Use only one H1 heading per page'); } // Check images $('img').each((i, img) => { const alt = $(img).attr('alt'); if (!alt && !$(img).attr('role')) { issues.push({ severity: 'medium', message: `Image missing alt text: ${ $(img).attr('src') || 'unknown image' }`, }); } }); const imagesWithoutAlt = $('img:not([alt])').length; if (imagesWithoutAlt > 0) { recommendations.push('Add alt text to all images'); } // Check for schema markup const schemas = []; $('script[type="application/ld+json"]').each((i, script) => { try { const schema = JSON.parse($(script).html()); schemas.push(schema); } catch (e) { issues.push({ severity: 'high', message: 'Invalid JSON-LD schema' }); } }); if (schemas.length === 0) { issues.push({ severity: 'medium', message: 'No structured data (schema.org) found', }); recommendations.push('Add structured data using JSON-LD'); } // Check for canonical URL if ($('link[rel="canonical"]').length === 0) { issues.push({ severity: 'medium', message: 'Missing canonical URL tag' }); recommendations.push('Add a canonical URL tag'); } // Check for viewport meta tag if ($('meta[name="viewport"]').length === 0) { issues.push({ severity: 'medium', message: 'Missing viewport meta tag' }); recommendations.push('Add a viewport meta tag for better mobile rendering'); } // Check for social media tags const hasOgTags = $('meta[property^="og:"]').length > 0; const hasTwitterTags = $('meta[name^="twitter:"]').length > 0; if (!hasOgTags) { issues.push({ severity: 'low', message: 'Missing Open Graph meta tags' }); recommendations.push( 'Add Open Graph meta tags for better social media sharing' ); } if (!hasTwitterTags) { issues.push({ severity: 'low', message: 'Missing Twitter Card meta tags' }); recommendations.push( 'Add Twitter Card meta tags for better Twitter sharing' ); } // React-specific recommendations if (hasReactRoot) { issues.push({ severity: 'info', message: 'This appears to be a React application with client-side rendering', }); recommendations.push( 'Consider using server-side rendering (Next.js) or static site generation (Gatsby) for better SEO' ); recommendations.push( 'Note: This analysis is limited to the static HTML. The rendered content may differ.' ); } return { pageIdentifier, title, metaDescription, headingStructure: { h1: h1Count, h2: h2Count, h3: h3Count, }, schemaCount: schemas.length, issues, recommendations, isReactApp: hasReactRoot, }; } // Format a single analysis result function formatAnalysisResult(result) { return `SEO ANALYSIS FOR: ${result.pageIdentifier} PAGE INFO: - Title: ${result.title || 'Missing'} (${ result.title ? result.title.length : 0 } chars) - Meta Description: ${result.metaDescription || 'Missing'} (${ result.metaDescription ? result.metaDescription.length : 0 } chars) - Heading Structure: H1: ${result.headingStructure.h1}, H2: ${ result.headingStructure.h2 }, H3: ${result.headingStructure.h3} - Schema Count: ${result.schemaCount} ${result.isReactApp ? '- React App: Yes (client-side rendering detected)' : ''} ISSUES: ${result.issues .map((issue) => `- [${issue.severity.toUpperCase()}] ${issue.message}`) .join('\n')} RECOMMENDATIONS: ${result.recommendations.map((rec) => `- ${rec}`).join('\n')} ${ result.isReactApp ? '\nNOTE: This is a static HTML analysis. For JavaScript-heavy sites like React apps, the rendered content may differ from the static HTML.' : '' }`; } // Format directory analysis results function formatDirectoryAnalysisResults(results, directoryPath) { let output = `SEO ANALYSIS FOR DIRECTORY: ${directoryPath}\n\n`; output += `Analyzed ${results.length} HTML files\n\n`; // Count issues by severity const issueCounts = { high: 0, medium: 0, low: 0, info: 0, }; results.forEach((result) => { result.issues.forEach((issue) => { if (issue.severity in issueCounts) { issueCounts[issue.severity]++; } }); }); output += `ISSUE SUMMARY:\n`; output += `- High: ${issueCounts.high}\n`; output += `- Medium: ${issueCounts.medium}\n`; output += `- Low: ${issueCounts.low}\n`; output += `- Info: ${issueCounts.info}\n\n`; // Check if any React apps were detected const reactApps = results.filter((r) => r.isReactApp); if (reactApps.length > 0) { output += `REACT APPLICATIONS DETECTED: ${reactApps.length} files\n`; output += `Note: For React applications, this analysis is limited to the static HTML. The rendered content may differ.\n`; output += `Consider using server-side rendering (Next.js) or static site generation (Gatsby) for better SEO.\n\n`; } // Individual file results results.forEach((result, index) => { output += `FILE ${index + 1}: ${result.pageIdentifier}\n`; output += `- Title: ${result.title || 'Missing'} (${ result.title ? result.title.length : 0 } chars)\n`; output += `- Meta Description: ${result.metaDescription || 'Missing'} (${ result.metaDescription ? result.metaDescription.length : 0 } chars)\n`; output += `- Heading Structure: H1: ${result.headingStructure.h1}, H2: ${result.headingStructure.h2}, H3: ${result.headingStructure.h3}\n`; output += `- Schema Count: ${result.schemaCount}\n`; output += `- Issues: ${result.issues.length}\n\n`; }); // Common recommendations const allRecommendations = new Set(); results.forEach((result) => { result.recommendations.forEach((rec) => { allRecommendations.add(rec); }); }); output += `COMMON RECOMMENDATIONS:\n`; Array.from(allRecommendations).forEach((rec) => { output += `- ${rec}\n`; }); return output; } // Start the server async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('SEO Inspector MCP Server running on stdio'); } runServer().catch((error) => { console.error('Fatal error running server:', error); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mgsrevolver/seo-inspector-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server