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)