Files
CleanArchitecture-template/.brain/.agent/skills/engineering-advanced-skills/mcp-server-builder/scripts/mcp_validator.py
2026-03-12 15:17:52 +07:00

187 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate MCP tool manifest files for common contract issues.
Input sources:
- --input <manifest.json>
- stdin JSON
Validation domains:
- structural correctness
- naming hygiene
- schema consistency
- descriptive completeness
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
TOOL_NAME_RE = re.compile(r"^[a-z0-9_]{3,64}$")
class CLIError(Exception):
"""Raised for expected CLI failures."""
@dataclass
class ValidationResult:
errors: List[str]
warnings: List[str]
tool_count: int
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate MCP tool definitions.")
parser.add_argument("--input", help="Path to manifest JSON file. If omitted, reads from stdin.")
parser.add_argument("--strict", action="store_true", help="Exit non-zero when errors are found.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def load_manifest(input_path: Optional[str]) -> Dict[str, Any]:
if input_path:
try:
data = Path(input_path).read_text(encoding="utf-8")
except Exception as exc:
raise CLIError(f"Failed reading --input: {exc}") from exc
else:
if sys.stdin.isatty():
raise CLIError("No input provided. Use --input or pipe manifest JSON via stdin.")
data = sys.stdin.read().strip()
if not data:
raise CLIError("Empty stdin.")
try:
payload = json.loads(data)
except json.JSONDecodeError as exc:
raise CLIError(f"Invalid JSON input: {exc}") from exc
if not isinstance(payload, dict):
raise CLIError("Manifest root must be a JSON object.")
return payload
def validate_schema(tool_name: str, schema: Dict[str, Any]) -> Tuple[List[str], List[str]]:
errors: List[str] = []
warnings: List[str] = []
if schema.get("type") != "object":
errors.append(f"{tool_name}: inputSchema.type must be 'object'.")
props = schema.get("properties", {})
if not isinstance(props, dict):
errors.append(f"{tool_name}: inputSchema.properties must be an object.")
props = {}
required = schema.get("required", [])
if not isinstance(required, list):
errors.append(f"{tool_name}: inputSchema.required must be an array.")
required = []
prop_keys = set(props.keys())
for req in required:
if req not in prop_keys:
errors.append(f"{tool_name}: required field '{req}' is not defined in properties.")
if not props:
warnings.append(f"{tool_name}: no input properties declared.")
for pname, pdef in props.items():
if not isinstance(pdef, dict):
errors.append(f"{tool_name}: property '{pname}' must be an object.")
continue
ptype = pdef.get("type")
if not ptype:
warnings.append(f"{tool_name}: property '{pname}' has no explicit type.")
return errors, warnings
def validate_manifest(payload: Dict[str, Any]) -> ValidationResult:
errors: List[str] = []
warnings: List[str] = []
tools = payload.get("tools")
if not isinstance(tools, list):
raise CLIError("Manifest must include a 'tools' array.")
seen_names = set()
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
errors.append(f"tool[{idx}] is not an object.")
continue
name = str(tool.get("name", "")).strip()
desc = str(tool.get("description", "")).strip()
schema = tool.get("inputSchema")
if not name:
errors.append(f"tool[{idx}] missing name.")
continue
if name in seen_names:
errors.append(f"duplicate tool name: {name}")
seen_names.add(name)
if not TOOL_NAME_RE.match(name):
warnings.append(
f"{name}: non-standard naming; prefer lowercase snake_case (3-64 chars, [a-z0-9_])."
)
if len(desc) < 10:
warnings.append(f"{name}: description too short; provide actionable purpose.")
if not isinstance(schema, dict):
errors.append(f"{name}: missing or invalid inputSchema object.")
continue
schema_errors, schema_warnings = validate_schema(name, schema)
errors.extend(schema_errors)
warnings.extend(schema_warnings)
return ValidationResult(errors=errors, warnings=warnings, tool_count=len(tools))
def to_text(result: ValidationResult) -> str:
lines = [
"MCP manifest validation",
f"- tools: {result.tool_count}",
f"- errors: {len(result.errors)}",
f"- warnings: {len(result.warnings)}",
]
if result.errors:
lines.append("Errors:")
lines.extend([f"- {item}" for item in result.errors])
if result.warnings:
lines.append("Warnings:")
lines.extend([f"- {item}" for item in result.warnings])
return "\n".join(lines)
def main() -> int:
args = parse_args()
payload = load_manifest(args.input)
result = validate_manifest(payload)
if args.format == "json":
print(json.dumps(asdict(result), indent=2))
else:
print(to_text(result))
if args.strict and result.errors:
return 1
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)