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 { z } from "zod" import { spawn } from "child_process" import path from "path" import { fileURLToPath } from "url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const DEFAULT_TIMEOUT = 120000 const ValidateToolInputJsonSchema = { type: "object", additionalProperties: false, required: ["plugin_path"], properties: { plugin_path: { type: "string", description: "Absolute or relative path to the WordPress plugin directory to validate" }, verbose: { type: "boolean", description: "Include full script output in addition to structured results (default: false)" }, }, } const server = new Server( { name: "wordpress-validator", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ) server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "validate_wordpress_plugin", description: `Validates a WordPress plugin for security vulnerabilities, coding standards violations, and common runtime errors. Runs comprehensive static analysis including: - Forbidden/dangerous function detection - SQL injection pattern detection - XSS and input sanitization checks - Nonce and capability verification - PHP syntax validation - Duplicate class/function detection - Class loading validation - File path security checks - WordPress deprecated function detection CRITICAL: This tool MUST be called before completing ANY WordPress plugin work. Do NOT mark work complete until validation passes. Returns structured results with severity categorization. Use verbose=true only if you need full output.`, inputSchema: ValidateToolInputJsonSchema, }, ], } }) server.setRequestHandler(CallToolRequestSchema, async (req) => { const toolName = req.params.name const args = (req.params.arguments ?? {}) if (toolName === "validate_wordpress_plugin") { const parsed = z .object({ plugin_path: z.string().min(1), verbose: z.boolean().optional().default(false), }) .safeParse(args && typeof args === "object" ? args : {}) if (!parsed.success) { return { content: [{ type: "text", text: JSON.stringify({ passed: false, errorCount: 1, warningCount: 0, summary: `Validation failed: ${parsed.error.message}`, issues: [{ severity: "error", category: "input", message: parsed.error.message }] }, null, 2) }], isError: true, } } const pluginPath = parsed.data.plugin_path const verbose = parsed.data.verbose || false const resolvedPath = path.isAbsolute(pluginPath) ? pluginPath : path.resolve(process.cwd(), pluginPath) // Check if directory exists try { const fs = await import("fs/promises") const stat = await fs.stat(resolvedPath) if (!stat.isDirectory()) { return { content: [{ type: "text", text: JSON.stringify({ passed: false, errorCount: 1, warningCount: 0, summary: `Validation failed: Not a directory: ${resolvedPath}`, issues: [{ severity: "error", category: "path", message: "Plugin path must be a directory" }] }, null, 2) }], isError: true, } } } catch (err) { return { content: [{ type: "text", text: JSON.stringify({ passed: false, errorCount: 1, warningCount: 0, summary: `Validation failed: Directory not found: ${resolvedPath}`, issues: [{ severity: "error", category: "path", message: `Plugin directory does not exist: ${resolvedPath}` }] }, null, 2) }], isError: true, } } // Find the validation script const scriptDir = path.resolve(__dirname, "../../../../scripts") const bashScript = path.join(scriptDir, "validate-wordpress-plugin.sh") // Check if script exists try { const fs = await import("fs/promises") await fs.access(bashScript) } catch { return { content: [{ type: "text", text: JSON.stringify({ passed: false, errorCount: 1, warningCount: 0, summary: "Validation failed: Validation script not found", issues: [{ severity: "error", category: "setup", message: `Validation script not found: ${bashScript}` }] }, null, 2) }], isError: true, } } // Run the validation script const output = await runValidationScript(bashScript, resolvedPath, DEFAULT_TIMEOUT) // Parse the validation output const result = parseValidationOutput(output) // Build concise summary const summaryParts = [] if (result.errorCount > 0) { summaryParts.push(`${result.errorCount} errors`) result.passed = false } if (result.warningCount > 0) { summaryParts.push(`${result.warningCount} warnings`) } if (result.errorCount === 0 && result.warningCount === 0) { summaryParts.push("SUCCESS") result.passed = true } result.summary = result.passed ? "All validation checks passed" : `Validation failed: ${summaryParts.join(", ")}` // Build output string let outputText = `\n` outputText += `${result.summary}\n\n` const errors = result.issues.filter(i => i.severity === "error") const warnings = result.issues.filter(i => i.severity === "warning") if (errors.length > 0) { outputText += `\n` errors.slice(0, 10).forEach(issue => { outputText += ` [${issue.category}] ${issue.file ? `${issue.file}:${issue.line} - ` : ""}${issue.message}\n` }) if (errors.length > 10) { outputText += ` ... and ${errors.length - 10} more errors\n` } outputText += `\n\n` } if (warnings.length > 0) { outputText += `\n` warnings.slice(0, 5).forEach(issue => { outputText += ` [${issue.category}] ${issue.file ? `${issue.file}:${issue.line} - ` : ""}${issue.message}\n` }) if (warnings.length > 5) { outputText += ` ... and ${warnings.length - 5} more warnings\n` } outputText += `\n` } outputText += `` if (verbose) { outputText += `\n\n\n${output.slice(-3000)}\n` } return { content: [{ type: "text", text: outputText }], } } return { content: [{ type: "text", text: `Unknown tool: ${toolName}` }], isError: true, } }) async function runValidationScript(scriptPath, pluginPath, timeout) { return new Promise((resolve, reject) => { const proc = spawn("bash", [scriptPath, pluginPath], { stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", }) let output = "" proc.stdout?.on("data", (chunk) => { output += chunk.toString() }) proc.stderr?.on("data", (chunk) => { output += chunk.toString() }) let timedOut = false const timeoutTimer = setTimeout(() => { timedOut = true proc.kill("SIGTERM") setTimeout(() => proc.kill("SIGKILL"), 5000) }, timeout) proc.on("exit", () => { clearTimeout(timeoutTimer) if (timedOut) { output += "\n\n[Validation timed out after " + timeout + "ms]" } resolve(output) }) proc.on("error", (err) => { clearTimeout(timeoutTimer) reject(err) }) }) } function parseValidationOutput(output) { const result = { passed: true, errorCount: 0, warningCount: 0, issues: [], summary: "", } const lines = output.split("\n") for (const line of lines) { const trimmed = line.trim() if (trimmed.includes("✗") || trimmed.includes("FATAL") || trimmed.includes("ERROR")) { const issue = parseIssueLine(trimmed, "error") if (issue && !isDuplicateIssue(result.issues, issue)) { result.issues.push(issue) result.errorCount++ } } if (trimmed.includes("⚠") || trimmed.includes("WARNING")) { const issue = parseIssueLine(trimmed, "warning") if (issue && !isDuplicateIssue(result.issues, issue)) { result.issues.push(issue) result.warningCount++ } } } return result } function parseIssueLine(line, severity) { const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, "") const fileMatch = cleanLine.match(/([\w\/\\.-]+\.php):(\d+)/) const file = fileMatch ? fileMatch[1] : undefined const lineNum = fileMatch ? parseInt(fileMatch[2], 10) : undefined const categoryMatch = cleanLine.match(/\[\d+\/\d+\]\s+Checking\s+(?:for\s+)?(.+?)\.\.\./i) const category = categoryMatch ? categoryMatch[1] : extractCategory(cleanLine) let message = cleanLine .replace(/^\s*[✗⚠]\s*/, "") .replace(/^\s*FATAL:\s*/i, "") .replace(/^\s*ERROR:\s*/i, "") .replace(/^\s*WARNING:\s*/i, "") .replace(/^\s*SECURITY\s+RISK\s+in\s+/, "") .replace(/^\s*SQL\s+INJECTION\s+RISK\s+in\s+/, "") .replace(/^\s*Found\s+/, "") .trim() if (!message || message.length < 10) { return null } return { severity, category, file, line: lineNum, message: message.substring(0, 200), } } function extractCategory(line) { if (line.includes("forbidden") || line.includes("eval") || line.includes("exec")) { return "security" } if (line.includes("SQL") || line.includes("wpdb")) { return "sql-injection" } if (line.includes("XSS") || line.includes("sanitize") || line.includes("escape")) { return "xss" } if (line.includes("syntax") || line.includes("parse")) { return "syntax" } if (line.includes("duplicate") || line.includes("redeclare")) { return "duplicates" } if (line.includes("missing") || line.includes("undefined")) { return "undefined" } if (line.includes("class") || line.includes("function")) { return "structure" } return "general" } function isDuplicateIssue(issues, newIssue) { return issues.some(i => i.message === newIssue.message && i.file === newIssue.file && i.line === newIssue.line ) } const transport = new StdioServerTransport() await server.connect(transport)