diff --git a/python/annotations/__init__.py b/python/annotations/__init__.py new file mode 100644 index 0000000..b8daf4b --- /dev/null +++ b/python/annotations/__init__.py @@ -0,0 +1,4 @@ +"""KiCad IPC API tool annotations package.""" +from .loader import AnnotationLoader + +__all__ = ["AnnotationLoader"] diff --git a/python/annotations/loader.py b/python/annotations/loader.py new file mode 100644 index 0000000..c51a2db --- /dev/null +++ b/python/annotations/loader.py @@ -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)" diff --git a/python/kicad_interface.py b/python/kicad_interface.py index b358445..4f87036 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -17,8 +17,11 @@ from typing import Any, Dict, List, Optional from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read -# Import tool schemas and resource definitions +# Import tool schemas, resource definitions, and IPC API annotations from schemas.tool_schemas import TOOL_SCHEMAS +from annotations import AnnotationLoader + +_annotation_loader = AnnotationLoader() # Configure logging log_dir = os.path.join(os.path.expanduser("~"), ".kicad-mcp", "logs") @@ -4664,22 +4667,32 @@ def main() -> None: # Return list of available tools with proper schemas tools = [] for cmd_name in interface.command_routes.keys(): - # Get schema from TOOL_SCHEMAS if available if cmd_name in TOOL_SCHEMAS: - tool_def = TOOL_SCHEMAS[cmd_name].copy() + # Enrich the existing schema with IPC annotation data + # (adds description/blocking hints where the schema lacks them) + tool_def = _annotation_loader.enrich_schema( + cmd_name, TOOL_SCHEMAS[cmd_name] + ) tools.append(tool_def) else: - # Fallback for tools without schemas - logger.warning(f"No schema defined for tool: {cmd_name}") + # Build a best-effort schema from IPC annotations + ann_desc = _annotation_loader.description(cmd_name) + if ann_desc: + logger.debug(f"Using IPC annotation for tool: {cmd_name}") + else: + logger.warning(f"No schema or annotation for tool: {cmd_name}") tools.append( - { - "name": cmd_name, - "description": f"KiCAD command: {cmd_name}", - "inputSchema": { - "type": "object", - "properties": {}, + _annotation_loader.enrich_schema( + cmd_name, + { + "name": cmd_name, + "description": ann_desc or f"KiCAD command: {cmd_name}", + "inputSchema": { + "type": "object", + "properties": {}, + }, }, - } + ) ) logger.info(f"Returning {len(tools)} tools")