""" AnnotationLoader — resolves KiCad IPC proto annotations by MCP tool name. The annotations JSON (generated by scripts/generate_tool_annotations.py) maps proto message names in PascalCase to structured descriptions. MCP tool names are snake_case and don't always match 1:1, so resolution uses three layers: 1. An explicit TOOL_TO_PROTO mapping for cases where auto-conversion fails. 2. Automatic snake_case → PascalCase conversion (get_nets → GetNets). 3. Suffix-stripped variants (get_nets_list → GetNets). Usage:: loader = AnnotationLoader() # loads bundled annotations ann = loader.get("refill_zones") # -> {"description": ..., "blocking": True, ...} desc = loader.description("refill_zones") # -> str | None """ from __future__ import annotations import json import re from pathlib import Path from typing import Optional # --------------------------------------------------------------------------- # Explicit overrides where snake→Pascal conversion would produce the wrong name # --------------------------------------------------------------------------- TOOL_TO_PROTO: dict[str, str] = { # board "get_nets_list": "GetNets", "add_copper_pour": "AddZone", "get_board_info": "GetBoardEnabledLayers", "get_layer_list": "GetBoardEnabledLayers", "set_active_layer": "SetActiveLayer", "set_design_rules": "SetBoardDesignRules", "get_design_rules": "GetBoardDesignRules", "refill_zones": "RefillZones", # editor lifecycle "begin_commit": "BeginCommit", "end_commit": "EndCommit", # schematic "run_erc": "GetSchematicHierarchy", # closest available proto # common "check_kicad_ui": "Ping", } # --------------------------------------------------------------------------- # Annotation loader # --------------------------------------------------------------------------- _DEFAULT_ANNOTATIONS_PATH = Path(__file__).parent / "kicad_ipc_tool_annotations.json" def _snake_to_pascal(name: str) -> str: """Convert snake_case to PascalCase: get_nets_list → GetNetsList.""" return "".join(part.capitalize() for part in name.split("_")) def _candidate_names(tool_name: str) -> list[str]: """ Return a prioritised list of proto message names to try for a given tool name. Tries: 1. Explicit override from TOOL_TO_PROTO 2. Direct PascalCase conversion 3. PascalCase with common suffixes stripped (_list, _info, _view, _region) """ candidates: list[str] = [] if tool_name in TOOL_TO_PROTO: candidates.append(TOOL_TO_PROTO[tool_name]) pascal = _snake_to_pascal(tool_name) candidates.append(pascal) # Try stripping trailing noise words that appear in tool names but not proto names for suffix in ("List", "Info", "View", "Region", "Violations", "Url"): if pascal.endswith(suffix): candidates.append(pascal[: -len(suffix)]) return candidates class AnnotationLoader: """ Loads and queries KiCad IPC API tool annotations. Parameters ---------- path: Path to the annotations JSON file. Defaults to the bundled file at ``python/annotations/kicad_ipc_tool_annotations.json``. """ def __init__(self, path: Optional[Path] = None) -> None: self._path = path or _DEFAULT_ANNOTATIONS_PATH self._annotations: dict[str, dict] = {} self._load() def _load(self) -> None: if not self._path.exists(): return try: data = json.loads(self._path.read_text(encoding="utf-8")) self._annotations = data.get("annotations", {}) except (json.JSONDecodeError, OSError): pass # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def get(self, tool_name: str) -> Optional[dict]: """Return the full annotation dict for a tool, or None if not found.""" for candidate in _candidate_names(tool_name): ann = self._annotations.get(candidate) if ann: return ann return None def description(self, tool_name: str) -> Optional[str]: """Return the description string for a tool, or None if not found.""" ann = self.get(tool_name) return ann.get("description") if ann else None def enrich_schema(self, tool_name: str, schema: dict) -> dict: """ Return a copy of ``schema`` enriched with annotation data. Fills in ``description`` and ``annotations`` (MCP ToolAnnotations) if the schema doesn't already have them. Existing values are preserved. """ ann = self.get(tool_name) if not ann: return schema schema = schema.copy() if not schema.get("description") and ann.get("description"): schema["description"] = ann["description"] # Build MCP ToolAnnotations block from blocking/interactive flags mcp_hints: dict[str, bool] = {} if ann.get("blocking"): # Blocking tools are read-only from the caller's perspective # (they mutate KiCad state but don't modify files via the API) mcp_hints["readOnlyHint"] = False if ann.get("interactive"): mcp_hints["readOnlyHint"] = False if ann.get("warnings"): schema.setdefault("_warnings", ann["warnings"]) if mcp_hints: schema.setdefault("annotations", {}).update(mcp_hints) return schema def summary(self) -> dict[str, str]: """Return a dict of proto_message_name → description for all loaded annotations.""" return {k: v.get("description", "") for k, v in self._annotations.items()} def __len__(self) -> int: return len(self._annotations) def __repr__(self) -> str: return f"AnnotationLoader({self._path}, {len(self)} annotations)"