feat: load IPC API annotations at startup to enrich tool descriptions
Adds python/annotations/loader.py (AnnotationLoader) which resolves MCP
tool names to KiCad IPC proto message annotations using three layers:
1. Explicit TOOL_TO_PROTO mapping for non-obvious name pairs
2. Automatic snake_case → PascalCase conversion
3. Suffix-stripped variants (get_nets_list → GetNets)
Integrates into kicad_interface.py tools/list handler:
- Tools with existing schemas: enrich_schema() adds blocking/interactive
ToolAnnotations hints and fills description gaps
- Tools without schemas (fallback): gets a real description from the
IPC annotation instead of the generic "KiCAD command: <name>" string
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
169
python/annotations/loader.py
Normal file
169
python/annotations/loader.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
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)"
|
||||
Reference in New Issue
Block a user