import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { renderMetadata, resolveMetadata, renderSitemap, renderRobots, createDefaultRobots, createArticleJsonLd, createBreadcrumbJsonLd, createFAQJsonLd, createProductJsonLd, createWebSiteJsonLd, createOrganizationJsonLd, createLocalBusinessJsonLd, createVideoJsonLd, createEventJsonLd, type Metadata, type Sitemap, type RobotsFile, type JsonLd, } from "@mandujs/core"; import { getProjectPaths } from "../utils/project.js"; import * as fs from "fs"; import * as path from "path"; export const seoToolDefinitions: Tool[] = [ { name: "mandu.seo.preview", description: "Preview rendered SEO HTML for given metadata. Useful for testing metadata before applying.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { metadata: { type: "object", description: "Metadata object to render (title, description, openGraph, twitter, etc.)", }, }, required: ["metadata"], }, }, { name: "mandu.seo.sitemap", description: "Generate sitemap.xml preview from entries", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { entries: { type: "array", description: "Array of sitemap entries with url, lastModified, changeFrequency, priority", items: { type: "object", properties: { url: { type: "string", description: "Page URL" }, lastModified: { type: "string", description: "Last modified date (ISO string)" }, changeFrequency: { type: "string", enum: ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"], }, priority: { type: "number", description: "Priority 0.0 to 1.0" }, images: { type: "array", items: { type: "string" }, description: "Image URLs for this page", }, }, required: ["url"], }, }, }, required: ["entries"], }, }, { name: "mandu.seo.robots", description: "Generate robots.txt preview from configuration", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { rules: { type: "object", description: "Robots rules (userAgent, allow, disallow, crawlDelay)", }, sitemap: { type: "string", description: "Sitemap URL", }, }, required: [], }, }, { name: "mandu.seo.jsonld", description: "Create JSON-LD structured data for SEO. Supports Article, WebSite, Organization, Breadcrumb, FAQ, Product, LocalBusiness, Video, Event types.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { type: { type: "string", enum: ["Article", "WebSite", "Organization", "Breadcrumb", "FAQ", "Product", "LocalBusiness", "Video", "Event"], description: "Type of JSON-LD to create", }, data: { type: "object", description: "Data for the JSON-LD (varies by type)", }, }, required: ["type", "data"], }, }, { name: "mandu.seo.write", description: "Write SEO configuration file (sitemap.ts or robots.ts) to app directory", annotations: { destructiveHint: true, readOnlyHint: false, }, inputSchema: { type: "object", properties: { fileType: { type: "string", enum: ["sitemap", "robots"], description: "Type of SEO file to create", }, config: { type: "object", description: "Configuration object for the file", }, }, required: ["fileType"], }, }, { name: "mandu.seo.analyze", description: "Analyze SEO metadata for common issues and provide recommendations", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { metadata: { type: "object", description: "Metadata object to analyze", }, url: { type: "string", description: "Page URL for context", }, }, required: ["metadata"], }, }, ]; export function seoTools(projectRoot: string) { const _paths = getProjectPaths(projectRoot); const handlers: Record) => Promise> = { "mandu.seo.preview": async (args: Record) => { const { metadata } = args as { metadata: Metadata }; try { // Create resolved metadata from input const resolved = await resolveMetadata([metadata]); const html = renderMetadata(resolved); return { success: true, html, resolved: { title: resolved.title?.absolute, description: resolved.description, hasOpenGraph: !!resolved.openGraph, hasTwitter: !!resolved.twitter, hasJsonLd: !!resolved.jsonLd && resolved.jsonLd.length > 0, }, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, "mandu.seo.sitemap": async (args: Record) => { const { entries } = args as { entries: Sitemap }; try { // Convert date strings to Date objects const processedEntries = entries.map((entry) => ({ ...entry, lastModified: entry.lastModified ? new Date(entry.lastModified as string) : undefined, })); const xml = renderSitemap(processedEntries); return { success: true, xml, entryCount: entries.length, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, "mandu.seo.robots": async (args: Record) => { const { rules, sitemap } = args as { rules?: RobotsFile["rules"]; sitemap?: string; }; try { const robotsConfig: RobotsFile = rules ? { rules, sitemap } : createDefaultRobots(sitemap || ""); const txt = renderRobots(robotsConfig); return { success: true, txt, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, "mandu.seo.jsonld": async (args: Record) => { const { type, data } = args as { type: string; data: Record }; try { let jsonLd: JsonLd; switch (type) { case "Article": jsonLd = createArticleJsonLd(data as Parameters[0]); break; case "WebSite": jsonLd = createWebSiteJsonLd(data as Parameters[0]); break; case "Organization": jsonLd = createOrganizationJsonLd(data as Parameters[0]); break; case "Breadcrumb": jsonLd = createBreadcrumbJsonLd(data as unknown as Array<{ name: string; url: string }>); break; case "FAQ": jsonLd = createFAQJsonLd(data as unknown as Array<{ question: string; answer: string }>); break; case "Product": jsonLd = createProductJsonLd(data as Parameters[0]); break; case "LocalBusiness": jsonLd = createLocalBusinessJsonLd(data as Parameters[0]); break; case "Video": jsonLd = createVideoJsonLd(data as Parameters[0]); break; case "Event": jsonLd = createEventJsonLd(data as Parameters[0]); break; default: return { success: false, error: `Unknown JSON-LD type: ${type}`, }; } return { success: true, jsonLd, script: ``, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, "mandu.seo.write": async (args: Record) => { const { fileType, config: _config } = args as { fileType: "sitemap" | "robots"; config?: Record; }; try { const appDir = path.join(projectRoot, "app"); // Ensure app directory exists if (!fs.existsSync(appDir)) { fs.mkdirSync(appDir, { recursive: true }); } let filePath: string; let content: string; if (fileType === "sitemap") { filePath = path.join(appDir, "sitemap.ts"); content = `import type { Sitemap } from '@mandujs/core' export default function sitemap(): Sitemap { return [ { url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1.0, }, // Add more entries here ] } `; } else { filePath = path.join(appDir, "robots.ts"); content = `import type { RobotsFile } from '@mandujs/core' export default function robots(): RobotsFile { return { rules: { userAgent: '*', allow: '/', disallow: ['/admin', '/private'], }, sitemap: 'https://example.com/sitemap.xml', } } `; } fs.writeFileSync(filePath, content, "utf-8"); return { success: true, filePath, message: `Created ${fileType}.ts at ${filePath}`, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, "mandu.seo.analyze": async (args: Record) => { const { metadata, url: _url } = args as { metadata: Metadata; url?: string }; const issues: Array<{ severity: "error" | "warning" | "info"; message: string }> = []; const recommendations: string[] = []; // Title checks if (!metadata.title) { issues.push({ severity: "error", message: "Missing title" }); } else if (typeof metadata.title === "string") { if (metadata.title.length < 10) { issues.push({ severity: "warning", message: "Title is too short (< 10 chars)" }); } else if (metadata.title.length > 60) { issues.push({ severity: "warning", message: "Title is too long (> 60 chars)" }); } } // Description checks if (!metadata.description) { issues.push({ severity: "error", message: "Missing description" }); } else if (metadata.description.length < 50) { issues.push({ severity: "warning", message: "Description is too short (< 50 chars)" }); } else if (metadata.description.length > 160) { issues.push({ severity: "warning", message: "Description is too long (> 160 chars)" }); } // Open Graph checks if (!metadata.openGraph) { issues.push({ severity: "warning", message: "Missing Open Graph metadata" }); recommendations.push("Add openGraph with title, description, and images for better social sharing"); } else { if (!metadata.openGraph.images) { issues.push({ severity: "warning", message: "Missing Open Graph image" }); } } // Twitter checks if (!metadata.twitter) { issues.push({ severity: "info", message: "Missing Twitter Card metadata" }); recommendations.push("Add twitter card metadata for Twitter sharing"); } // JSON-LD checks if (!metadata.jsonLd) { recommendations.push("Add JSON-LD structured data for rich search results"); } // Viewport check if (!metadata.viewport) { recommendations.push("Add viewport meta for mobile optimization"); } // Calculate score const errorCount = issues.filter((i) => i.severity === "error").length; const warningCount = issues.filter((i) => i.severity === "warning").length; const score = Math.max(0, 100 - errorCount * 20 - warningCount * 10); return { score, issues, recommendations, summary: `SEO Score: ${score}/100 (${errorCount} errors, ${warningCount} warnings)`, }; }, }; // Backward-compatible aliases (deprecated) handlers["mandu_preview_seo"] = handlers["mandu.seo.preview"]; handlers["mandu_seo_analyze"] = handlers["mandu.seo.analyze"]; handlers["mandu_generate_sitemap_preview"] = handlers["mandu.seo.sitemap"]; handlers["mandu_generate_robots_preview"] = handlers["mandu.seo.robots"]; handlers["mandu_create_jsonld"] = handlers["mandu.seo.jsonld"]; handlers["mandu_write_seo_file"] = handlers["mandu.seo.write"]; return handlers; }