#!/usr/bin/env python3 """ Normalize Whetstone MCP tool schemas into a canonical, recursion-safe structure. """ from __future__ import annotations import argparse import json from pathlib import Path from typing import Any, Dict, List, Set, Tuple SUPPORTED_CONSTRAINT_KEYS = { "enum", "const", "pattern", "minLength", "maxLength", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minItems", "maxItems", } def _json_pointer_get(doc: Any, ref: str) -> Any: if not ref.startswith("#/"): raise ValueError(f"Only local refs are supported: {ref}") cur = doc for tok in ref[2:].split("/"): tok = tok.replace("~1", "/").replace("~0", "~") if not isinstance(cur, dict) or tok not in cur: raise KeyError(f"Ref path not found: {ref}") cur = cur[tok] return cur def _infer_type(node: Dict[str, Any]) -> str: t = node.get("type") if isinstance(t, str): return t if isinstance(t, list): if "null" in t and len(t) == 2: return next(x for x in t if x != "null") if "properties" in node: return "object" if "items" in node or "prefixItems" in node: return "array" if "enum" in node: vals = node["enum"] if vals and all(isinstance(v, str) for v in vals): return "string" return "any" def _extract_constraints(node: Dict[str, Any]) -> Dict[str, Any]: out: Dict[str, Any] = {} for k in SUPPORTED_CONSTRAINT_KEYS: if k in node: out[k] = node[k] return out def normalize_node( node: Any, *, root_doc: Dict[str, Any], path: str, unsupported: List[Dict[str, Any]], ref_stack: Set[str], ) -> Dict[str, Any]: if not isinstance(node, dict): unsupported.append({"path": path, "reason": "non_object_schema_node"}) return {"kind": "any", "path": path} if "$ref" in node: ref = node["$ref"] if not isinstance(ref, str): unsupported.append({"path": path, "reason": "invalid_ref"}) return {"kind": "any", "path": path} if ref in ref_stack: unsupported.append({"path": path, "reason": "ref_cycle", "ref": ref}) return {"kind": "any", "path": path} try: target = _json_pointer_get(root_doc, ref) except Exception as exc: unsupported.append( {"path": path, "reason": "ref_resolution_failed", "ref": ref, "error": str(exc)} ) return {"kind": "any", "path": path} return normalize_node( target, root_doc=root_doc, path=f"{path}->$ref({ref})", unsupported=unsupported, ref_stack=ref_stack | {ref}, ) for key in ("not", "if", "then", "else", "dependentSchemas", "patternProperties"): if key in node: unsupported.append({"path": path, "reason": "unsupported_keyword", "keyword": key}) if "oneOf" in node: branches = node.get("oneOf") or [] return { "kind": "oneOf", "path": path, "branches": [ normalize_node( b, root_doc=root_doc, path=f"{path}.oneOf[{i}]", unsupported=unsupported, ref_stack=ref_stack, ) for i, b in enumerate(branches) ], } if "anyOf" in node: branches = node.get("anyOf") or [] return { "kind": "anyOf", "path": path, "branches": [ normalize_node( b, root_doc=root_doc, path=f"{path}.anyOf[{i}]", unsupported=unsupported, ref_stack=ref_stack, ) for i, b in enumerate(branches) ], } if "allOf" in node: branches = node.get("allOf") or [] return { "kind": "allOf", "path": path, "branches": [ normalize_node( b, root_doc=root_doc, path=f"{path}.allOf[{i}]", unsupported=unsupported, ref_stack=ref_stack, ) for i, b in enumerate(branches) ], } t = _infer_type(node) constraints = _extract_constraints(node) if t == "object": props = node.get("properties", {}) or {} required = sorted([x for x in node.get("required", []) if isinstance(x, str)]) normalized_props: Dict[str, Any] = {} for key in sorted(props.keys()): normalized_props[key] = normalize_node( props[key], root_doc=root_doc, path=f"{path}.properties.{key}", unsupported=unsupported, ref_stack=ref_stack, ) ap = node.get("additionalProperties", True) additional_properties = ( normalize_node( ap, root_doc=root_doc, path=f"{path}.additionalProperties", unsupported=unsupported, ref_stack=ref_stack, ) if isinstance(ap, dict) else bool(ap) ) return { "kind": "object", "path": path, "properties": normalized_props, "required": required, "additionalProperties": additional_properties, "constraints": constraints, "allowBroad": bool(node.get("x-whetstone-allow-broad", False)), } if t == "array": if "prefixItems" in node and isinstance(node["prefixItems"], list): items = [ normalize_node( it, root_doc=root_doc, path=f"{path}.prefixItems[{i}]", unsupported=unsupported, ref_stack=ref_stack, ) for i, it in enumerate(node["prefixItems"]) ] additional = node.get("items", False) add_items = ( normalize_node( additional, root_doc=root_doc, path=f"{path}.items", unsupported=unsupported, ref_stack=ref_stack, ) if isinstance(additional, dict) else bool(additional) ) return { "kind": "arrayTuple", "path": path, "prefixItems": items, "additionalItems": add_items, "constraints": constraints, "allowBroad": bool(node.get("x-whetstone-allow-broad", False)), } return { "kind": "array", "path": path, "items": normalize_node( node.get("items", {}), root_doc=root_doc, path=f"{path}.items", unsupported=unsupported, ref_stack=ref_stack, ), "constraints": constraints, "allowBroad": bool(node.get("x-whetstone-allow-broad", False)), } if t in ("string", "integer", "number", "boolean", "null"): out = {"kind": t, "path": path, "constraints": constraints} if "enum" in node: out["enum"] = node["enum"] if "const" in node: out["const"] = node["const"] return out return { "kind": "any", "path": path, "constraints": constraints, "allowBroad": bool(node.get("x-whetstone-allow-broad", False)), } def normalize_tool_schemas(raw: Dict[str, Any]) -> Dict[str, Any]: out: Dict[str, Any] = {} for tool_name in sorted(raw.keys()): schema = raw[tool_name] unsupported: List[Dict[str, Any]] = [] normalized = normalize_node( schema, root_doc=raw, path=f"tools.{tool_name}", unsupported=unsupported, ref_stack=set(), ) out[tool_name] = { "schema": normalized, "unsupported": unsupported, } return out def _summarize(normalized: Dict[str, Any]) -> Dict[str, Any]: unsupported_count = sum(len(v.get("unsupported", [])) for v in normalized.values()) return { "tool_count": len(normalized), "unsupported_entry_count": unsupported_count, } def main() -> None: ap = argparse.ArgumentParser() ap.add_argument( "--schemas", default="tools/mcp/whetstone_tool_schemas.json", help="Input tool schema map", ) ap.add_argument( "--out", default="tools/mcp/grammars/normalized_tool_schemas.json", help="Output normalized schema file", ) args = ap.parse_args() schema_path = Path(args.schemas) raw = json.loads(schema_path.read_text()) normalized = normalize_tool_schemas(raw) payload = { "summary": _summarize(normalized), "tools": normalized, } out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") print(f"Wrote normalized schemas: {out_path}") print(json.dumps(payload["summary"], indent=2, sort_keys=True)) if __name__ == "__main__": main()