Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.
"""
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)"