Skip to content

Commit 0b5e8db

Browse files
kingpanther13claude
andcommitted
feat: dual toggles, feature-gated stubs, tool_search_max_results, grouping fix
Settings UI rework: - Replace dropdown with two toggles (enabled + pinned) per tool - Pinned toggle disabled/grayed out when enabled toggle is off - Add banner note explaining pinning only applies with tool search - Show feature-gated tools (ha_config_set_yaml, filesystem tools) as stub entries with a "Requires X in add-on config" note — their toggles are locked since they can't be enabled at runtime Tool grouping fix: - Use local_provider._list_tools() to see ALL registered tools regardless of runtime enable state (so users can re-enable them) - Sort tags alphabetically and prefer non-secondary tags for primary group (Device Registry instead of Z-Wave for ha_get_device) Config additions: - tool_search_max_results field in addon-dev config.yaml + translations - disabled_tools/pinned_tools text fields as seed values - start.py wires all new env vars through Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1da91b2 commit 0b5e8db

5 files changed

Lines changed: 178 additions & 34 deletions

File tree

homeassistant-addon-dev/config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ options:
2424
enable_skills_as_tools: false
2525
enable_tool_search: false
2626
enable_yaml_config_editing: false
27+
tool_search_max_results: 5
2728
disabled_tools: ""
2829
pinned_tools: ""
2930
schema:
@@ -33,6 +34,7 @@ schema:
3334
enable_skills_as_tools: bool?
3435
enable_tool_search: bool?
3536
enable_yaml_config_editing: bool?
37+
tool_search_max_results: int?
3638
disabled_tools: str?
3739
pinned_tools: str?
3840
ports:

homeassistant-addon-dev/translations/en.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ configuration:
3535
like homeassistant, http, and recorder are blocked. A backup is
3636
created before every edit. Use for YAML-only features that have no
3737
UI or API alternative. Requires restart to take effect.
38+
tool_search_max_results:
39+
name: Tool search max results
40+
description: >-
41+
Maximum number of tools returned by ha_search_tools when tool
42+
search is enabled. Lower values (2-3) save context tokens but
43+
may miss relevant tools. Range: 2-10. Requires restart.
3844
disabled_tools:
3945
name: Disabled tools (text fallback)
4046
description: >-

homeassistant-addon/start.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ def main() -> int:
110110
enable_skills_as_tools = False # default
111111
enable_tool_search = False # default
112112
enable_yaml_config_editing = False # default
113+
tool_search_max_results = 5 # default
114+
disabled_tools_raw = "" # default
115+
pinned_tools_raw = "" # default
113116

114117
if config_file.exists():
115118
try:
@@ -125,6 +128,12 @@ def main() -> int:
125128
enable_tool_search = raw_tool_search if isinstance(raw_tool_search, bool) else False
126129
raw_yaml_config = config.get("enable_yaml_config_editing", False)
127130
enable_yaml_config_editing = raw_yaml_config if isinstance(raw_yaml_config, bool) else False
131+
raw_max_results = config.get("tool_search_max_results", 5)
132+
tool_search_max_results = raw_max_results if isinstance(raw_max_results, int) else 5
133+
raw_disabled = config.get("disabled_tools", "")
134+
disabled_tools_raw = raw_disabled if isinstance(raw_disabled, str) else ""
135+
raw_pinned = config.get("pinned_tools", "")
136+
pinned_tools_raw = raw_pinned if isinstance(raw_pinned, str) else ""
128137
except Exception as e:
129138
log_error(f"Failed to read config: {e}, using defaults")
130139

@@ -140,6 +149,9 @@ def main() -> int:
140149
os.environ["ENABLE_SKILLS_AS_TOOLS"] = str(enable_skills_as_tools).lower()
141150
os.environ["ENABLE_TOOL_SEARCH"] = str(enable_tool_search).lower()
142151
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
152+
os.environ["TOOL_SEARCH_MAX_RESULTS"] = str(tool_search_max_results)
153+
os.environ["DISABLED_TOOLS"] = disabled_tools_raw
154+
os.environ["PINNED_TOOLS"] = pinned_tools_raw
143155

144156
# Validate Supervisor token
145157
supervisor_token = os.environ.get("SUPERVISOR_TOKEN")

src/ha_mcp/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ class Settings(BaseSettings):
118118
disabled_tools: str = Field("", alias="DISABLED_TOOLS")
119119
pinned_tools: str = Field("", alias="PINNED_TOOLS")
120120

121+
# Max results returned by ha_search_tools (2-10).
122+
tool_search_max_results: int = Field(5, alias="TOOL_SEARCH_MAX_RESULTS")
123+
121124
@model_validator(mode="after")
122125
def _skills_dependency(self) -> "Settings":
123126
"""Auto-enable skills (resources) when skills-as-tools is on.

src/ha_mcp/settings_ui.py

Lines changed: 155 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,48 @@
3333
"ha_report_issue",
3434
}
3535

36+
# Tools that exist in the codebase but are only registered when a
37+
# corresponding feature flag/env var is set. When the flag is off, these
38+
# won't appear in local_provider._list_tools(), so we inject stub entries
39+
# into the settings UI with a read-only "disabled by" note.
40+
FEATURE_GATED_TOOLS: dict[str, dict[str, str]] = {
41+
"ha_config_set_yaml": {
42+
"title": "Set YAML Config",
43+
"primary_tag": "System",
44+
"description": "Add, replace, or remove top-level keys in configuration.yaml or package files.",
45+
"disabled_by": "enable_yaml_config_editing",
46+
"destructiveHint": "true",
47+
},
48+
"ha_list_files": {
49+
"title": "List Files",
50+
"primary_tag": "Files",
51+
"description": "List files in a directory within the Home Assistant config.",
52+
"disabled_by": "HAMCP_ENABLE_FILESYSTEM_TOOLS",
53+
"readOnlyHint": "true",
54+
},
55+
"ha_read_file": {
56+
"title": "Read File",
57+
"primary_tag": "Files",
58+
"description": "Read a file from the Home Assistant config directory.",
59+
"disabled_by": "HAMCP_ENABLE_FILESYSTEM_TOOLS",
60+
"readOnlyHint": "true",
61+
},
62+
"ha_write_file": {
63+
"title": "Write File",
64+
"primary_tag": "Files",
65+
"description": "Write a file to allowed directories in the Home Assistant config.",
66+
"disabled_by": "HAMCP_ENABLE_FILESYSTEM_TOOLS",
67+
"destructiveHint": "true",
68+
},
69+
"ha_delete_file": {
70+
"title": "Delete File",
71+
"primary_tag": "Files",
72+
"description": "Delete a file from allowed directories.",
73+
"disabled_by": "HAMCP_ENABLE_FILESYSTEM_TOOLS",
74+
"destructiveHint": "true",
75+
},
76+
}
77+
3678

3779
def _get_config_path() -> Path:
3880
"""Return the path to the tool config JSON file."""
@@ -93,13 +135,21 @@ def save_tool_config(config: dict[str, Any]) -> None:
93135
async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[str, Any]]:
94136
"""Extract metadata for all registered tools from the server.
95137
96-
Reads live from FastMCP's list_tools() — always reflects the currently
97-
registered tools, including any runtime enable/disable state.
138+
Reads from the local provider's unfiltered tool list so that disabled
139+
tools are still shown in the settings UI (users need to be able to
140+
re-enable them).
98141
"""
99142
tools: list[dict[str, Any]] = []
100-
registered = await server.mcp.list_tools()
143+
# Groups not considered "primary" when choosing a tool's canonical group —
144+
# these are cross-cutting tags (e.g. Z-Wave, Zigbee) that should not
145+
# override the tool's real domain group.
146+
secondary_tags = {"Z-Wave", "Zigbee"}
147+
148+
registered = await server.mcp.local_provider._list_tools() # type: ignore[attr-defined]
101149
for tool in registered:
102-
tags = list(tool.tags) if tool.tags else []
150+
tags = sorted(tool.tags) if tool.tags else []
151+
primary_tags = [t for t in tags if t not in secondary_tags]
152+
primary = primary_tags[0] if primary_tags else (tags[0] if tags else "Other")
103153
annotations: dict[str, bool] = {}
104154
if tool.annotations:
105155
if getattr(tool.annotations, "readOnlyHint", None):
@@ -114,9 +164,31 @@ async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[s
114164
"title": title,
115165
"description": (tool.description or "")[:200],
116166
"tags": tags,
167+
"primary_tag": primary,
117168
"annotations": annotations,
118169
})
119-
tools.sort(key=lambda t: (t["tags"][0] if t["tags"] else "zzz", t["name"]))
170+
171+
# Inject stub entries for feature-gated tools that aren't registered
172+
registered_names = {t["name"] for t in tools}
173+
for name, meta in FEATURE_GATED_TOOLS.items():
174+
if name in registered_names:
175+
continue
176+
stub_annotations: dict[str, bool] = {}
177+
if meta.get("readOnlyHint") == "true":
178+
stub_annotations["readOnlyHint"] = True
179+
if meta.get("destructiveHint") == "true":
180+
stub_annotations["destructiveHint"] = True
181+
tools.append({
182+
"name": name,
183+
"title": meta["title"],
184+
"description": meta["description"],
185+
"tags": [meta["primary_tag"]],
186+
"primary_tag": meta["primary_tag"],
187+
"annotations": stub_annotations,
188+
"disabled_by": meta["disabled_by"],
189+
})
190+
191+
tools.sort(key=lambda t: (t["primary_tag"], t["name"]))
120192
return tools
121193

122194

@@ -212,14 +284,27 @@ def apply_tool_visibility(
212284
.badge.readonly { background: #1a2a3a; color: #6cb4ff; }
213285
.badge.destructive { background: #3a1a1a; color: #ff6b6b; }
214286
.badge.mandatory { background: #1a3a1a; color: #6bff6b; }
215-
.tool-select { min-width: 140px; padding: 6px 10px; border-radius: 8px;
216-
border: 1px solid var(--border); background: var(--surface); color: var(--text);
217-
font-size: 0.85rem; cursor: pointer; }
218-
.tool-select:disabled { opacity: 0.4; cursor: not-allowed; background: var(--disabled-bg); }
219-
.tool-select option { background: var(--surface); }
287+
.tool-toggles { display: flex; gap: 16px; align-items: center; }
288+
.toggle-group { display: flex; flex-direction: column; align-items: center; gap: 2px;
289+
font-size: 0.7rem; color: var(--text-secondary); }
290+
.toggle-group.disabled-toggle { opacity: 0.35; }
291+
.switch { position: relative; display: inline-block; width: 36px; height: 20px; }
292+
.switch input { opacity: 0; width: 0; height: 0; }
293+
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
294+
background: #555; border-radius: 10px; transition: background 0.2s; }
295+
.slider::before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px;
296+
top: 3px; background: var(--text); border-radius: 50%; transition: transform 0.2s; }
297+
input:checked + .slider { background: var(--accent); }
298+
input:checked + .slider::before { transform: translateX(16px); }
299+
input:disabled + .slider { cursor: not-allowed; opacity: 0.4; }
300+
.disabled-by-note { font-size: 0.7rem; color: var(--warning); margin-top: 2px;
301+
font-style: italic; }
220302
.summary { display: flex; gap: 16px; padding: 8px 0; margin-bottom: 16px;
221303
font-size: 0.85rem; color: var(--text-secondary); flex-wrap: wrap; }
222304
.summary span { background: var(--surface); padding: 4px 12px; border-radius: 8px; }
305+
.pin-notice { background: #3a2e1a; border: 1px solid #7a5a1a; border-radius: 10px;
306+
padding: 10px 16px; margin-bottom: 12px; font-size: 0.85rem; color: #ffd680; display: none; }
307+
.pin-notice.show { display: block; }
223308
</style>
224309
</head>
225310
<body>
@@ -231,6 +316,11 @@ def apply_tool_visibility(
231316
Safety toggles (Enable Skills, Tool Search, YAML Config Editing) are managed in the
232317
add-on configuration page and require a restart to change.
233318
</div>
319+
<div class="pin-notice show" id="pinNotice">
320+
Pin toggles only take effect when Tool Search is enabled in the add-on
321+
configuration. Without Tool Search, all enabled tools are always visible
322+
and pinning has no extra effect.
323+
</div>
234324
<div class="summary" id="summary"></div>
235325
<input type="text" class="search" id="search" placeholder="Search tools...">
236326
<div id="groups"></div>
@@ -248,24 +338,26 @@ def apply_tool_visibility(
248338
updateStatus('Loaded');
249339
}
250340
341+
const DEFAULT_PINNED = """ + json.dumps(list(DEFAULT_PINNED_TOOLS)) + """;
342+
const MANDATORY = """ + json.dumps(list(MANDATORY_TOOLS)) + """;
343+
251344
function getState(name) {
252345
if (toolStates[name]) return toolStates[name];
253-
const defs = """ + json.dumps(list(DEFAULT_PINNED_TOOLS)) + """;
254-
return defs.includes(name) ? 'pinned' : 'enabled';
346+
return DEFAULT_PINNED.includes(name) ? 'pinned' : 'enabled';
255347
}
256348
257349
function render() {
258350
const groups = {};
259351
toolData.forEach(t => {
260-
const tag = (t.tags && t.tags[0]) || 'Other';
352+
const tag = t.primary_tag || (t.tags && t.tags[0]) || 'Other';
261353
if (!groups[tag]) groups[tag] = [];
262354
groups[tag].push(t);
263355
});
264356
265357
const container = document.getElementById('groups');
266358
container.innerHTML = '';
267359
268-
let total = 0, enabled = 0, pinned = 0, disabled = 0;
360+
let total = 0, enabledCount = 0, pinnedCount = 0, disabledCount = 0;
269361
270362
Object.keys(groups).sort().forEach(tag => {
271363
const tools = groups[tag];
@@ -274,9 +366,9 @@ def apply_tool_visibility(
274366
275367
const header = document.createElement('div');
276368
header.className = 'group-header';
277-
const enabledCount = tools.filter(t => getState(t.name) !== 'disabled').length;
369+
const groupEnabled = tools.filter(t => getState(t.name) !== 'disabled').length;
278370
header.innerHTML = `<div><span class="group-name">${tag}</span>` +
279-
`<span class="group-count">${enabledCount}/${tools.length} enabled</span></div>` +
371+
`<span class="group-count">${groupEnabled}/${tools.length} enabled</span></div>` +
280372
`<span class="group-chevron">&#9654;</span>`;
281373
header.onclick = () => {
282374
const toolsDiv = group.querySelector('.group-tools');
@@ -290,15 +382,23 @@ def apply_tool_visibility(
290382
291383
tools.forEach(t => {
292384
const state = getState(t.name);
293-
const isMandatory = """ + json.dumps(list(MANDATORY_TOOLS)) + """.includes(t.name);
385+
const isMandatory = MANDATORY.includes(t.name);
386+
const disabledBy = t.disabled_by || null;
387+
const isFeatureGated = disabledBy !== null;
294388
const ann = t.annotations || {};
295389
const isReadOnly = ann.readOnlyHint === true;
296390
const isDestructive = ann.destructiveHint === true;
297391
298392
total++;
299-
if (state === 'disabled') disabled++;
300-
else if (state === 'pinned') pinned++;
301-
else enabled++;
393+
if (isFeatureGated) disabledCount++;
394+
else if (state === 'disabled') disabledCount++;
395+
else if (state === 'pinned') { enabledCount++; pinnedCount++; }
396+
else enabledCount++;
397+
398+
const isEnabled = isFeatureGated ? false : (isMandatory || state !== 'disabled');
399+
const isPinned = isFeatureGated ? false : (isMandatory || state === 'pinned' || DEFAULT_PINNED.includes(t.name));
400+
const lockEnabled = isMandatory || isFeatureGated;
401+
const lockPinned = isMandatory || isFeatureGated || !isEnabled;
302402
303403
const div = document.createElement('div');
304404
div.className = 'tool';
@@ -312,26 +412,47 @@ def apply_tool_visibility(
312412
313413
const title = t.title || t.name;
314414
const desc = (t.description || '').split('\\n')[0].slice(0, 120);
415+
const gatedNote = disabledBy ? `<div class="disabled-by-note">Requires ${disabledBy} in add-on config</div>` : '';
315416
316417
div.innerHTML = `<div class="tool-info">` +
317418
`<div class="tool-name">${title}${badges}</div>` +
318419
`<div class="tool-meta">${t.name}</div>` +
319420
(desc ? `<div class="tool-desc">${desc}</div>` : '') +
421+
gatedNote +
320422
`</div>` +
321-
`<select class="tool-select" data-tool="${t.name}" ${isMandatory ? 'disabled' : ''}>` +
322-
`<option value="enabled" ${state === 'enabled' ? 'selected' : ''}>Enabled</option>` +
323-
`<option value="pinned" ${state === 'pinned' ? 'selected' : ''}>Pinned</option>` +
324-
`<option value="disabled" ${state === 'disabled' ? 'selected' : ''}>Disabled</option>` +
325-
`</select>`;
326-
327-
const select = div.querySelector('select');
328-
if (select && !isMandatory) {
329-
select.addEventListener('change', (e) => {
330-
toolStates[t.name] = e.target.value;
423+
`<div class="tool-toggles">` +
424+
`<div class="toggle-group">` +
425+
`<label class="switch"><input type="checkbox" data-tool="${t.name}" data-field="enabled" ` +
426+
`${isEnabled ? 'checked' : ''} ${lockEnabled ? 'disabled' : ''}>` +
427+
`<span class="slider"></span></label>` +
428+
`<span>enabled</span>` +
429+
`</div>` +
430+
`<div class="toggle-group ${!isEnabled ? 'disabled-toggle' : ''}">` +
431+
`<label class="switch"><input type="checkbox" data-tool="${t.name}" data-field="pinned" ` +
432+
`${isPinned ? 'checked' : ''} ${lockPinned ? 'disabled' : ''}>` +
433+
`<span class="slider"></span></label>` +
434+
`<span>pinned</span>` +
435+
`</div>` +
436+
`</div>`;
437+
438+
const inputs = div.querySelectorAll('input[type="checkbox"]');
439+
inputs.forEach(input => {
440+
if (input.disabled) return;
441+
input.addEventListener('change', (e) => {
442+
const field = e.target.dataset.field;
443+
const currentState = getState(t.name);
444+
let newState = currentState;
445+
if (field === 'enabled') {
446+
if (!e.target.checked) newState = 'disabled';
447+
else newState = (currentState === 'pinned') ? 'pinned' : 'enabled';
448+
} else if (field === 'pinned') {
449+
newState = e.target.checked ? 'pinned' : 'enabled';
450+
}
451+
toolStates[t.name] = newState;
331452
scheduleSave();
332453
render();
333454
});
334-
}
455+
});
335456
toolsDiv.appendChild(div);
336457
});
337458
@@ -342,9 +463,9 @@ def apply_tool_visibility(
342463
343464
document.getElementById('summary').innerHTML =
344465
`<span>${total} total</span>` +
345-
`<span style="color:var(--success)">${enabled} enabled</span>` +
346-
`<span style="color:var(--accent)">${pinned} pinned</span>` +
347-
`<span style="color:var(--danger)">${disabled} disabled</span>`;
466+
`<span style="color:var(--success)">${enabledCount} enabled</span>` +
467+
`<span style="color:var(--accent)">${pinnedCount} pinned</span>` +
468+
`<span style="color:var(--danger)">${disabledCount} disabled</span>`;
348469
}
349470
350471
function scheduleSave() {

0 commit comments

Comments
 (0)