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:
Samuel Price
2026-04-19 11:32:12 -04:00
parent 8253550715
commit 91f4a05a00
3 changed files with 198 additions and 12 deletions

View File

@@ -0,0 +1,4 @@
"""KiCad IPC API tool annotations package."""
from .loader import AnnotationLoader
__all__ = ["AnnotationLoader"]

View 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)"

View File

@@ -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(
_annotation_loader.enrich_schema(
cmd_name,
{
"name": cmd_name,
"description": f"KiCAD command: {cmd_name}",
"description": ann_desc or f"KiCAD command: {cmd_name}",
"inputSchema": {
"type": "object",
"properties": {},
},
}
},
)
)
logger.info(f"Returning {len(tools)} tools")