seo_score
Analyze markdown content for SEO quality by providing a keyword. Get a 0-100 score, grade, and detailed breakdown. Basic analysis is free; full analysis with prioritized suggestions and keyword gap detection uses 1 credit.
Instructions
Analyze markdown content for SEO quality and return a 0-100 score with grade. Basic scoring (heading structure, word count, keyword density, readability) requires no credentials and is FREE. Full analysis (issue list, prioritized suggestions, related-keyword gaps) costs 1 credit. Returns: { score, grade, breakdown, issues?, suggestions? }. Common errors: missing 'content' (VALIDATION_ERROR), insufficient credits for full analysis (PAYMENT_REQUIRED).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| content | Yes | The markdown content to analyze | |
| keyword | Yes | The target keyword or phrase |
Implementation Reference
- src/tools/seo-tools.ts:21-41 (handler)Handler function for the seo_score tool. Validates input (content, keyword), calls scoreContent() for full analysis. If no credits, returns a reduced result (score, readability, density, word count) with a note to purchase credits.
export async function handleSeoScore(input: z.infer<typeof seoScoreSchema>) { const contentErr = validateRequired(input.content, "content"); if (contentErr) return makeError("VALIDATION_ERROR", contentErr); const keywordErr = validateRequired(input.keyword, "keyword"); if (keywordErr) return makeError("VALIDATION_ERROR", keywordErr); const result = scoreContent(input.content, input.keyword); if (!hasCredits()) { return makeSuccess({ score: result.score, readability: result.readability, keyword_density: result.keyword_density, word_count: result.word_count, note: "Purchase credits at pipepost.dev to unlock full SEO analysis with issues and suggestions", }); } return makeSuccess(result); } - src/tools/seo-tools.ts:13-16 (schema)Zod schema for seo_score tool input: requires 'content' (markdown string) and 'keyword' (target keyword).
export const seoScoreSchema = z.object({ content: z.string().describe("The markdown content to analyze"), keyword: z.string().describe("The target keyword or phrase"), }); - src/index.ts:106-110 (registration)Registers the 'seo_score' tool on the MCP server with description, schema shape, and handler callback. Parses input, calls handleSeoScore, and formats the response.
server.tool("seo_score", "Analyze markdown content for SEO quality and return a 0-100 score with grade. Basic scoring (heading structure, word count, keyword density, readability) requires no credentials and is FREE. Full analysis (issue list, prioritized suggestions, related-keyword gaps) costs 1 credit. Returns: { score, grade, breakdown, issues?, suggestions? }. Common errors: missing 'content' (VALIDATION_ERROR), insufficient credits for full analysis (PAYMENT_REQUIRED).", seoScoreSchema.shape, async (input) => { const parsed = seoScoreSchema.parse(input); const result = await handleSeoScore(parsed); return { content: [{ type: "text", text: formatToolResponse("seo_score", result, formatSeoScore) }] }; }); - src/seo/score.ts:83-176 (helper)Core scoring logic: computes composite SEO score (0-100) from word count, Flesch-Kincaid readability, keyword density, heading structure, and keyword placement. Includes issue/suggestion generation.
export function scoreContent(content: string, keyword: string): SeoScore { const words = countWords(content); const headings = countHeadings(content); const fk = fleschKincaid(content); const density = keywordDensity(content, keyword); const issues: string[] = []; const suggestions: string[] = []; if (words === 0) { return { score: 0, readability: { flesch_kincaid: 0, grade_level: "N/A" }, keyword_density: 0, word_count: 0, heading_structure: headings, issues: ["Content is empty"], suggestions: ["Add content to analyze"], }; } // Word count checks if (words < 300) { issues.push(`Content is under 300 words (got ${words})`); suggestions.push("Aim for at least 800-1,500 words for SEO"); } else if (words < 800) { suggestions.push("Consider expanding to 1,000+ words for better ranking potential"); } // Heading checks if (headings.h1 === 0) { issues.push("Missing H1 heading"); } else if (headings.h1 > 1) { issues.push(`Multiple H1 headings found (${headings.h1}) — use only one`); } if (headings.h2 === 0 && words > 300) { issues.push("No H2 subheadings — break content into sections"); } // Keyword checks if (density === 0) { issues.push(`Target keyword "${keyword}" not found in content`); } else if (density > 3) { issues.push(`Keyword density too high (${density}%) — may be flagged as keyword stuffing`); suggestions.push("Aim for 1-2% keyword density"); } else if (density < 0.5) { suggestions.push(`Low keyword density (${density}%) — consider adding "${keyword}" in key sections`); } // Check keyword in headings const kwLower = keyword.toLowerCase(); const headingLines = content.split("\n").filter((l) => l.trim().startsWith("#")); const kwInHeading = headingLines.some((l) => l.toLowerCase().includes(kwLower)); if (!kwInHeading && headingLines.length > 0) { issues.push(`Target keyword "${keyword}" not found in any heading`); suggestions.push("Include the target keyword in at least one heading"); } // Calculate composite score let score = 50; // base // Word count contribution (0-20) if (words >= 1500) score += 20; else if (words >= 800) score += 15; else if (words >= 300) score += 8; else score -= 10; // Readability contribution (0-15) if (fk >= 50 && fk <= 80) score += 15; // sweet spot else if (fk >= 30) score += 8; // Keyword density contribution (0-15) if (density >= 0.5 && density <= 2.5) score += 15; else if (density > 0 && density < 3.5) score += 8; // Structure contribution (0-10) if (headings.h1 === 1) score += 5; if (headings.h2 >= 2) score += 5; // Penalty per issue score -= issues.length * 5; score = Math.max(0, Math.min(100, score)); return { score, readability: { flesch_kincaid: fk, grade_level: gradeLevel(fk) }, keyword_density: density, word_count: words, heading_structure: headings, issues, suggestions, }; } - src/format-response.ts:158-210 (helper)Formats the seo_score result into a human-readable string with breakdown table, issues list, suggestions, and optional note.
export function formatSeoScore(data: unknown): string { const d = data as { score: number; readability: { flesch_kincaid: number; grade_level: string }; keyword_density: number; word_count: number; heading_structure?: { h1: number; h2: number; h3: number }; issues?: string[]; suggestions?: string[]; note?: string; }; const readTime = Math.max(1, Math.ceil(d.word_count / 200)); const lines = [ `# SEO Score: ${d.score}/100`, "", statBar(d.score, 100), "", section( "Breakdown", table( ["Category", "Details"], [ ["Readability", `${d.readability.grade_level} (Flesch-Kincaid ${d.readability.flesch_kincaid})`], ["Keywords", `Density: ${d.keyword_density}%`], ...(d.heading_structure ? [["Structure" as string | number, `H1: ${d.heading_structure.h1} \u00b7 H2: ${d.heading_structure.h2} \u00b7 H3: ${d.heading_structure.h3}` as string | number]] : []), ["Word Count", `${d.word_count.toLocaleString()} words (${readTime} min read)`], ] ) ), ]; if (d.issues && d.issues.length > 0) { lines.push( "", section("Issues", d.issues.map((i) => `\u2718 ${i}`).join("\n")) ); } if (d.suggestions && d.suggestions.length > 0) { lines.push( "", section("Suggestions", d.suggestions.map((s) => `\u2192 ${s}`).join("\n")) ); } if (d.note) { lines.push("", note(d.note)); } return lines.join("\n");