#!/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 Anthropic from "@anthropic-ai/sdk";
import * as cheerio from "cheerio";
interface PageAnalysis {
url: string;
statusCode: number;
title: string;
metaDescription: string;
h1Count: number;
h1Text: string[];
h2Count: number;
imgCount: number;
imgWithoutAlt: number;
hasViewport: boolean;
hasCanonical: boolean;
canonicalUrl: string;
hasRobots: boolean;
robotsContent: string;
isHttps: boolean;
hasFavicon: boolean;
hasOpenGraph: boolean;
ogTags: Record<string, string>;
hasTwitterCard: boolean;
linkCount: number;
externalLinkCount: number;
hasStructuredData: boolean;
structuredDataTypes: string[];
htmlLang: string;
headingStructure: string[];
formCount: number;
ariaLabelCount: number;
ariaRoleCount: number;
inlineStyleCount: number;
scriptCount: number;
stylesheetCount: number;
htmlSize: number;
hasHsts: boolean;
hasXFrameOptions: boolean;
hasContentSecurityPolicy: boolean;
hasXContentTypeOptions: boolean;
mixedContent: boolean;
responseHeaders: Record<string, string>;
}
interface AuditResult {
overallScore: number;
categories: {
seo: number;
performance: number;
mobile: number;
accessibility: number;
security: number;
};
summary: string;
issues: Array<{
severity: "critical" | "warning" | "info";
category: "SEO" | "Performance" | "Mobile" | "Accessibility" | "Security";
title: string;
description: string;
fix: string;
}>;
}
async function analyzePage(url: string): Promise<PageAnalysis> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "SiteScore/1.0 (Website Audit Tool)",
Accept: "text/html,application/xhtml+xml",
},
redirect: "follow",
});
clearTimeout(timeout);
const html = await res.text();
const $ = cheerio.load(html);
// Response headers
const headers: Record<string, string> = {};
res.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
// Meta tags
const title = $("title").first().text().trim();
const metaDescription = $('meta[name="description"]').attr("content") || "";
const hasViewport = $('meta[name="viewport"]').length > 0;
const hasCanonical = $('link[rel="canonical"]').length > 0;
const canonicalUrl = $('link[rel="canonical"]').attr("href") || "";
const hasRobots = $('meta[name="robots"]').length > 0;
const robotsContent = $('meta[name="robots"]').attr("content") || "";
const hasFavicon =
$('link[rel="icon"], link[rel="shortcut icon"]').length > 0;
// Headings
const h1Elements = $("h1");
const h1Text: string[] = [];
h1Elements.each((_, el) => {
h1Text.push($(el).text().trim());
});
const headingStructure: string[] = [];
$("h1, h2, h3, h4, h5, h6").each((_, el) => {
headingStructure.push(
`${el.tagName}: ${$(el).text().trim().slice(0, 60)}`
);
});
// Images
const images = $("img");
let imgWithoutAlt = 0;
images.each((_, el) => {
if (!$(el).attr("alt") && $(el).attr("alt") !== "") imgWithoutAlt++;
});
// Open Graph
const ogTags: Record<string, string> = {};
$('meta[property^="og:"]').each((_, el) => {
const prop = $(el).attr("property") || "";
ogTags[prop] = $(el).attr("content") || "";
});
// Twitter Card
const hasTwitterCard = $('meta[name^="twitter:"]').length > 0;
// Links
const links = $("a[href]");
let externalLinkCount = 0;
links.each((_, el) => {
const href = $(el).attr("href") || "";
if (href.startsWith("http") && !href.includes(new URL(url).hostname)) {
externalLinkCount++;
}
});
// Structured data
const structuredDataScripts = $('script[type="application/ld+json"]');
const structuredDataTypes: string[] = [];
structuredDataScripts.each((_, el) => {
try {
const data = JSON.parse($(el).html() || "{}");
if (data["@type"]) structuredDataTypes.push(data["@type"]);
} catch {
// ignore
}
});
// Accessibility
let ariaLabelCount = 0;
let ariaRoleCount = 0;
$("[aria-label]").each(() => {
ariaLabelCount++;
});
$("[role]").each(() => {
ariaRoleCount++;
});
// Security headers
const hasHsts = "strict-transport-security" in headers;
const hasXFrameOptions = "x-frame-options" in headers;
const hasContentSecurityPolicy = "content-security-policy" in headers;
const hasXContentTypeOptions = "x-content-type-options" in headers;
// Mixed content check
let mixedContent = false;
if (url.startsWith("https")) {
$("img[src], script[src], link[href]").each((_, el) => {
const src = $(el).attr("src") || $(el).attr("href") || "";
if (src.startsWith("http://")) mixedContent = true;
});
}
return {
url,
statusCode: res.status,
title,
metaDescription,
h1Count: h1Elements.length,
h1Text,
h2Count: $("h2").length,
imgCount: images.length,
imgWithoutAlt,
hasViewport,
hasCanonical,
canonicalUrl,
hasRobots,
robotsContent,
isHttps: url.startsWith("https"),
hasFavicon,
hasOpenGraph: Object.keys(ogTags).length > 0,
ogTags,
hasTwitterCard,
linkCount: links.length,
externalLinkCount,
hasStructuredData: structuredDataTypes.length > 0,
structuredDataTypes,
htmlLang: $("html").attr("lang") || "",
headingStructure,
formCount: $("form").length,
ariaLabelCount,
ariaRoleCount,
inlineStyleCount: $("[style]").length,
scriptCount: $("script").length,
stylesheetCount: $('link[rel="stylesheet"]').length,
htmlSize: html.length,
hasHsts,
hasXFrameOptions,
hasContentSecurityPolicy,
hasXContentTypeOptions,
mixedContent,
responseHeaders: headers,
};
} catch (err: unknown) {
clearTimeout(timeout);
const message = err instanceof Error ? err.message : "Unknown error";
throw new Error(`Failed to fetch page: ${message}`);
}
}
async function getAIAnalysis(analysis: PageAnalysis): Promise<AuditResult> {
const anthropic = new Anthropic();
const prompt = `You are a website audit expert. Analyze the following page data and provide a detailed audit report.
Page Analysis Data:
${JSON.stringify(analysis, null, 2)}
Respond with a JSON object (no markdown, no code fences, just raw JSON) with this exact structure:
{
"overallScore": <number 0-100>,
"categories": {
"seo": <number 0-100>,
"performance": <number 0-100>,
"mobile": <number 0-100>,
"accessibility": <number 0-100>,
"security": <number 0-100>
},
"summary": "<1-2 sentence overall summary>",
"issues": [
{
"severity": "critical" | "warning" | "info",
"category": "SEO" | "Performance" | "Mobile" | "Accessibility" | "Security",
"title": "<short title>",
"description": "<plain English explanation of the issue>",
"fix": "<concrete fix, code snippet or actionable step>"
}
]
}
Scoring guidelines:
- SEO: Check title length (50-60 chars ideal), meta description (120-160 chars), H1 presence (exactly 1), heading hierarchy, image alt text, canonical tags, Open Graph tags, structured data, lang attribute
- Performance: Consider HTML size, number of scripts, stylesheets, inline styles
- Mobile: Check viewport meta tag, touch-friendly patterns
- Accessibility: Check ARIA labels, roles, lang attribute, heading structure, form labels
- Security: Check HTTPS, HSTS, X-Frame-Options, CSP, X-Content-Type-Options, mixed content
Be specific and actionable with fixes. Include code snippets where appropriate.
List 5-15 issues, prioritized by severity.
Be realistic with scores — most sites have room for improvement.`;
const msg = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});
const responseText =
msg.content[0].type === "text" ? msg.content[0].text : "";
// Parse the JSON response
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("No JSON found in AI response");
}
return JSON.parse(jsonMatch[0]) as AuditResult;
}
async function analyzeWebsite(url: string): Promise<AuditResult> {
// Validate URL
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error("Invalid protocol - must be http or https");
}
} catch (e) {
if (e instanceof Error && e.message.includes("protocol")) {
throw e;
}
throw new Error(`Invalid URL: ${url}`);
}
// Analyze the page
const analysis = await analyzePage(parsedUrl.toString());
// Get AI analysis
const result = await getAIAnalysis(analysis);
return result;
}
// Create MCP server
const server = new Server(
{
name: "sitescore-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "analyze_website",
description:
"Analyze a website for SEO, performance, accessibility, mobile-friendliness, and security. Returns an overall score (0-100), category scores, summary, and detailed issues with fixes.",
inputSchema: {
type: "object" as const,
properties: {
url: {
type: "string",
description:
"The URL of the website to analyze (must include http:// or https://)",
},
},
required: ["url"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "analyze_website") {
throw new Error(`Unknown tool: ${request.params.name}`);
}
const args = request.params.arguments as { url?: string };
if (!args.url || typeof args.url !== "string") {
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ error: "URL is required" }),
},
],
isError: true,
};
}
try {
const result = await analyzeWebsite(args.url);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result, null, 2),
},
],
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ error: message }),
},
],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("SiteScore MCP server running on stdio");
}
main().catch(console.error);