Skip to content

Commit 6d800ef

Browse files
julienldclaude
andcommitted
fix: icon bundle and manifest + add annotation tests
- Update manifest.template.json to use "icon.png" (old-style) instead of "icon-128.png" - Update workflow to copy icon-512.png as icon.png for old-style icon reference - Add test_tool_annotations.py to verify all tools have proper MCP hints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c22f656 commit 6d800ef

3 files changed

Lines changed: 188 additions & 3 deletions

File tree

.github/workflows/build-binary.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ jobs:
154154
mv mcpb-bundle/ha-mcp-windows.exe mcpb-bundle/ha-mcp.exe
155155
mv mcpb-bundle/ha-mcp-macos-arm64 mcpb-bundle/ha-mcp
156156
157-
# Copy icons to bundle (light + dark variants + source SVG)
157+
# Copy icons to bundle (light + dark variants + main icon)
158158
cp packaging/mcpb/icon-16.png mcpb-bundle/
159159
cp packaging/mcpb/icon-32.png mcpb-bundle/
160160
cp packaging/mcpb/icon-64.png mcpb-bundle/
@@ -167,7 +167,7 @@ jobs:
167167
cp packaging/mcpb/icon-dark-128.png mcpb-bundle/
168168
cp packaging/mcpb/icon-dark-256.png mcpb-bundle/
169169
cp packaging/mcpb/icon-dark-512.png mcpb-bundle/
170-
cp packaging/mcpb/icon.svg mcpb-bundle/
170+
cp packaging/mcpb/icon-512.png mcpb-bundle/icon.png
171171
172172
# Generate manifest.json (multi-platform)
173173
python packaging/mcpb/generate_manifest.py "$VERSION"

packaging/mcpb/manifest.template.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"display_name": "Home Assistant MCP",
66
"description": "Control and manage Home Assistant with natural language",
77
"long_description": "Control and manage your Home Assistant smart home with natural language.\n\nWhat you can do:\n✅ Search and discover entities using fuzzy matching\n✅ Control lights, switches, covers, and climate devices\n✅ Create and manage automations and scripts\n✅ Query history, statistics, and system health\n✅ Organize areas, zones, and device groups\n\n80+ specialized tools give your AI assistant complete access to your smart home.",
8-
"icon": "icon-128.png",
8+
"icon": "icon.png",
99
"icons": [
1010
{ "src": "icon-16.png", "size": "16x16" },
1111
{ "src": "icon-32.png", "size": "32x32" },
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Tests for tool annotations compliance with MCP Directory Policy.
2+
3+
Every tool MUST have exactly one of:
4+
- readOnlyHint: true - For tools that only read data
5+
- destructiveHint: true - For tools that modify data or have side effects
6+
7+
Additionally, every tool SHOULD have a title for UI display.
8+
"""
9+
10+
import ast
11+
import re
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
17+
def get_tools_dir() -> Path:
18+
"""Get the path to the tools directory."""
19+
return Path(__file__).parent.parent.parent.parent / "src" / "ha_mcp" / "tools"
20+
21+
22+
def extract_tool_decorators(file_path: Path) -> list[dict]:
23+
"""Extract @mcp.tool decorator information from a Python file."""
24+
content = file_path.read_text(encoding="utf-8")
25+
tools = []
26+
27+
# Find all @mcp.tool decorators with their annotations
28+
# Pattern matches @mcp.tool(...) followed by async def function_name
29+
pattern = r'@mcp\.tool\(([^)]*)\)\s*(?:@\w+\s*)*async def (\w+)'
30+
31+
for match in re.finditer(pattern, content, re.DOTALL):
32+
decorator_args = match.group(1)
33+
func_name = match.group(2)
34+
35+
# Check for annotations
36+
has_read_only = 'readOnlyHint' in decorator_args and 'True' in decorator_args.split('readOnlyHint')[1][:20]
37+
has_destructive = 'destructiveHint' in decorator_args and 'True' in decorator_args.split('destructiveHint')[1][:20]
38+
has_title = 'title' in decorator_args
39+
40+
tools.append({
41+
'file': file_path.name,
42+
'function': func_name,
43+
'has_read_only_hint': has_read_only,
44+
'has_destructive_hint': has_destructive,
45+
'has_title': has_title,
46+
'decorator_args': decorator_args.strip(),
47+
})
48+
49+
# Also find bare @mcp.tool without arguments
50+
bare_pattern = r'@mcp\.tool\s*\n\s*(?:@\w+\s*)*async def (\w+)'
51+
for match in re.finditer(bare_pattern, content):
52+
func_name = match.group(1)
53+
tools.append({
54+
'file': file_path.name,
55+
'function': func_name,
56+
'has_read_only_hint': False,
57+
'has_destructive_hint': False,
58+
'has_title': False,
59+
'decorator_args': '',
60+
})
61+
62+
return tools
63+
64+
65+
def get_all_tools() -> list[dict]:
66+
"""Get all tools from all tool files."""
67+
tools_dir = get_tools_dir()
68+
all_tools = []
69+
70+
for py_file in sorted(tools_dir.glob("*.py")):
71+
if py_file.name.startswith("_"):
72+
continue
73+
tools = extract_tool_decorators(py_file)
74+
all_tools.extend(tools)
75+
76+
return all_tools
77+
78+
79+
class TestToolAnnotations:
80+
"""Test suite for MCP tool annotation compliance."""
81+
82+
def test_all_tools_have_required_hint(self):
83+
"""Every tool must have exactly one of readOnlyHint or destructiveHint."""
84+
tools = get_all_tools()
85+
86+
missing_hints = []
87+
both_hints = []
88+
89+
for tool in tools:
90+
has_read = tool['has_read_only_hint']
91+
has_destructive = tool['has_destructive_hint']
92+
93+
if not has_read and not has_destructive:
94+
missing_hints.append(f"{tool['file']}:{tool['function']}")
95+
elif has_read and has_destructive:
96+
both_hints.append(f"{tool['file']}:{tool['function']}")
97+
98+
error_msg = []
99+
if missing_hints:
100+
error_msg.append(
101+
f"Tools missing readOnlyHint or destructiveHint ({len(missing_hints)}):\n "
102+
+ "\n ".join(missing_hints)
103+
)
104+
if both_hints:
105+
error_msg.append(
106+
f"Tools with BOTH hints (should have exactly one) ({len(both_hints)}):\n "
107+
+ "\n ".join(both_hints)
108+
)
109+
110+
assert not error_msg, "\n\n".join(error_msg)
111+
112+
def test_all_tools_have_title(self):
113+
"""Every tool should have a title for UI display."""
114+
tools = get_all_tools()
115+
116+
missing_titles = [
117+
f"{tool['file']}:{tool['function']}"
118+
for tool in tools
119+
if not tool['has_title']
120+
]
121+
122+
assert not missing_titles, (
123+
f"Tools missing title annotation ({len(missing_titles)}):\n "
124+
+ "\n ".join(missing_titles)
125+
)
126+
127+
def test_tool_count_sanity_check(self):
128+
"""Sanity check that we're finding a reasonable number of tools."""
129+
tools = get_all_tools()
130+
131+
# We should have at least 50 tools (currently ~82)
132+
assert len(tools) >= 50, f"Only found {len(tools)} tools, expected at least 50"
133+
134+
# We should have a mix of read-only and destructive tools
135+
read_only_count = sum(1 for t in tools if t['has_read_only_hint'])
136+
destructive_count = sum(1 for t in tools if t['has_destructive_hint'])
137+
138+
assert read_only_count >= 20, f"Only {read_only_count} read-only tools, expected at least 20"
139+
assert destructive_count >= 20, f"Only {destructive_count} destructive tools, expected at least 20"
140+
141+
def test_read_only_tools_are_actually_read_only(self):
142+
"""Tools with readOnlyHint should have read-only names (get, list, search, etc)."""
143+
tools = get_all_tools()
144+
145+
read_only_tools = [t for t in tools if t['has_read_only_hint']]
146+
147+
# These prefixes/patterns indicate read-only operations
148+
read_only_patterns = ['get', 'list', 'search', 'check', 'eval', 'render']
149+
150+
suspicious = []
151+
for tool in read_only_tools:
152+
func = tool['function'].lower()
153+
# If it starts with a modifying verb, it's suspicious
154+
# Note: "list_updates" is OK (listing updates), "update_zone" is suspicious
155+
modifying_prefixes = ['create_', 'set_', 'delete_', 'update_', 'add_', 'remove_', 'assign_', 'restart', 'reload']
156+
if any(func.startswith(f'ha_{prefix}') or func.startswith(prefix) for prefix in modifying_prefixes):
157+
suspicious.append(f"{tool['file']}:{tool['function']}")
158+
159+
assert not suspicious, (
160+
f"Tools marked readOnlyHint but have modifying names ({len(suspicious)}):\n "
161+
+ "\n ".join(suspicious)
162+
)
163+
164+
def test_destructive_tools_are_actually_destructive(self):
165+
"""Tools with destructiveHint should have modifying names."""
166+
tools = get_all_tools()
167+
168+
destructive_tools = [t for t in tools if t['has_destructive_hint']]
169+
170+
# These patterns indicate destructive/modifying operations
171+
destructive_patterns = ['create', 'set', 'delete', 'update', 'add', 'remove', 'assign', 'restart', 'reload', 'restore', 'import', 'rename', 'call', 'bulk', 'config_set', 'config_delete']
172+
173+
suspicious = []
174+
for tool in destructive_tools:
175+
func = tool['function'].lower()
176+
# If it has only get/list/search patterns and destructiveHint, that's suspicious
177+
if not any(pattern in func for pattern in destructive_patterns):
178+
# Exception: ha_call_service and ha_bulk_control are correctly destructive
179+
if func not in ['ha_call_service', 'ha_bulk_control']:
180+
suspicious.append(f"{tool['file']}:{tool['function']}")
181+
182+
# This is a warning, not a hard failure - some tools might legitimately be destructive
183+
# even without obvious naming
184+
if suspicious:
185+
print(f"\nNote: These destructive tools don't have typical modifying names:\n " + "\n ".join(suspicious))

0 commit comments

Comments
 (0)