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
3779def _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:
93135async 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+
251344function 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
257349function 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">▶</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
350471function scheduleSave() {
0 commit comments