Skip to content

Commit 9783f34

Browse files
feat: web-based settings UI for per-tool enable/disable/pin (#960)
* feat: web-based settings UI for per-tool enable/disable/pin Add a self-contained HTML settings page served via FastMCP's custom_route at /settings. Provides searchable, grouped tool management with three states per tool: enabled, pinned, disabled. - GET /settings — serves the settings page (inline HTML/CSS/JS) - GET /api/settings/tools — returns tool metadata + current states - POST /api/settings/tools — saves states, applies immediately via mcp.disable()/mcp.enable() (no restart needed for tool changes) - Persists to tool_config.json in addon data dir or ~/.ha-mcp/ - Seeds from DISABLED_TOOLS/PINNED_TOOLS env vars on first run - Mandatory tools (ha_search_entities, ha_get_overview, ha_get_state, ha_report_issue) shown grayed out, cannot be disabled - enable_yaml_config_editing toggle respected as override - Works across all install methods (addon, Docker, standalone) - Dark theme matching HA aesthetic Closes #798 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CI failures and Gemini review comments - Import DEFAULT_PINNED_TOOLS from transforms (avoid duplication) - Catch specific exceptions (OSError, json.JSONDecodeError) instead of broad Exception - Fix mypy no-any-return: annotate json.loads return types - Fix ruff C401: use set comprehension instead of set(generator) - Fix ruff C420: use dict.fromkeys instead of dict comprehension - Use structured error format in POST endpoint responses - Make tools.json path discovery check multiple locations - Fix ValueError/TypeError catch for JSON parsing in POST handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add ingress config and text field fallbacks for addon - Enable ingress (shows "Open Web UI" button on addon info page) - Add disabled_tools/pinned_tools text fields as seed/fallback - Add translations for the new fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add ingress_stream, serve settings at root for ingress - Add ingress_stream: true for WebSocket passthrough - Remove panel_icon (not needed for "Open Web UI" button) - Serve settings page at both / and /settings so ingress root works - Ingress proxies to http://localhost:9583/ which needs a handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add ingress + text fields to addon-dev config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert homeassistant-addon/ to master (release pipeline handles it) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use live FastMCP list_tools() and relative fetch URLs - Replace broken _tool_manager._tools internal API with the public await mcp.list_tools() call - Remove tools.json fallback — was a dev-only path, wouldn't exist in production containers anyway - Use relative './api/settings/tools' fetch URLs so requests work both directly and through ingress proxy (ESPHome/Node-RED pattern) - Fix translation description wording ("on the addon info page") Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * fix: remove unused type: ignore on _list_tools * feat: preserve group open state, add per-group master toggle - Persist open groups in a Set that survives re-renders (fixes collapse-on-toggle-click bug where clicking any tool toggle would call render() and wipe the expanded state) - Add master enable/disable toggle per group in the header - Master toggle affects all non-mandatory, non-feature-gated tools - Stop propagation on master toggle so clicking it doesn't also expand/collapse the group Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: require restart to apply tool visibility changes Runtime mcp.enable/disable calls accumulate visibility transforms in the provider's _transforms list every save, causing stale transforms to pile up. More importantly, they don't reliably remove tools from the LLM's tool list in practice — tools still appear in list_tools() output with full schema, just fail at call time with "Unknown tool". New approach: save changes to tool_config.json and require an add-on restart. Startup-time apply_tool_visibility() reads the config and applies visibility once, cleanly. Disabled tools are then fully absent from list_tools() on next startup. - Remove runtime mcp.enable/disable from POST handler - Show "Saved — restart required" status after save - Show prominent red restart-required banner in UI Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add in-UI restart button using Supervisor API - New POST /api/settings/restart endpoint calls http://supervisor/addons/self/restart with SUPERVISOR_TOKEN - New GET /api/settings/info exposes whether running as add-on - Frontend shows "Restart Add-on" button in the restart notice banner (only visible when running as add-on) - Click opens a confirmation dialog and triggers the restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: treat dropped connection as success in restart handler The Supervisor kills our process while the restart request is in flight, causing httpx to throw ReadError/RemoteProtocolError. That's actually the SUCCESS path — the restart is happening. Catch those specifically and return success. Also surface the real Supervisor error message in the browser when a real failure occurs (was showing generic "Restart failed" before). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: document settings UI in addon DOCS and .env.example Add-on DOCS.md: - Document new options: enable_skills, enable_skills_as_tools, enable_tool_search, enable_yaml_config_editing, tool_search_max_results, disabled_tools, pinned_tools - Add "Tool Settings Web UI" section explaining the web UI features, restart requirement, and text-field fallback .env.example: - Add DISABLED_TOOLS, PINNED_TOOLS, TOOL_SEARCH_MAX_RESULTS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Gemini review feedback - Use create_error_response/ErrorCode for all REST endpoint errors (settings save, restart, validation) - Validate states dict from client (string keys, allowed state values) before persisting to disk - Type hint settings parameter as Settings (TYPE_CHECKING import) - Initialize _user_pinned_tools in __init__ instead of getattr fallback - Add clearer docstring explaining why _list_tools() (private API) is used: public list_tools() filters disabled tools, and the settings UI specifically needs the unfiltered list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unnecessary type annotation quotes (UP037) * fix: pass real FastMCP to register_settings_routes (mypy) mypy rejects the _DeferredMCP wrapper as FastMCP[Any]. _get_server() forces the lazy init, then we pass server.mcp (the real FastMCP) into register_settings_routes. register_browser_landing is left alone since its signature already accepts the union FastMCP | _DeferredMCP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ba80ae commit 9783f34

10 files changed

Lines changed: 1289 additions & 4 deletions

File tree

.env.example

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ LOG_LEVEL=INFO
3232
# top-level keys in configuration.yaml and packages/*.yaml.
3333
# ENABLE_YAML_CONFIG_EDITING=false
3434

35+
# Tool visibility seed values (comma-separated tool names). On first start
36+
# these are written to tool_config.json; after that the web settings UI at
37+
# http://<server>/settings is the source of truth. See DOCS.md for details.
38+
# DISABLED_TOOLS=
39+
# PINNED_TOOLS=
40+
41+
# Max results from ha_search_tools (2-10, default 5).
42+
# TOOL_SEARCH_MAX_RESULTS=5
43+
3544
# Optional: MCP Server Configuration
3645
MCP_SERVER_NAME=ha-mcp
37-
# MCP_SERVER_VERSION defaults to the package version (e.g. 6.7.2)
46+
# MCP_SERVER_VERSION defaults to the package version (e.g. 6.7.2)

homeassistant-addon-dev/DOCS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,41 @@ The dev add-on uses the same configuration as the stable version. See the main a
1414
|--------|-------------|---------|
1515
| `backup_hint` | Backup strength preference | `normal` |
1616
| `secret_path` | Custom secret path (optional) | auto-generated |
17+
| `enable_skills` | Serve bundled HA best-practice skills as MCP resources | `true` |
18+
| `enable_skills_as_tools` | Expose skills via list_resources/read_resource tools | `true` |
19+
| `enable_tool_search` | Replace full tool catalog with search-based discovery (~46K → ~5K tokens) | `false` |
1720
| `enable_yaml_config_editing` *(beta)* | Enables `ha_config_set_yaml` for editing `configuration.yaml` directly. Requires `ha_mcp_tools` custom component. | `false` |
1821
| `enable_filesystem_tools` *(beta)* | Enables file read/write tools (`ha_list_files`, `ha_read_file`, `ha_write_file`, `ha_delete_file`). Requires `ha_mcp_tools` custom component. | `false` |
1922
| `enable_custom_component_integration` *(beta)* | Enables `ha_install_mcp_tools` installer tool for the `ha_mcp_tools` custom component. | `false` |
23+
| `tool_search_max_results` | Max results from `ha_search_tools` (range 2-10) | `5` |
24+
| `disabled_tools` | Comma-separated list of tool names to disable (seed value; web UI is primary) | empty |
25+
| `pinned_tools` | Comma-separated list of tool names to pin when tool search is enabled (seed value; web UI is primary) | empty |
2026

2127
Beta options are hidden under "Show unused optional configuration options" in the add-on Configuration tab. See [beta.md](https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/beta.md) for details.
2228

29+
## Tool Settings Web UI
30+
31+
The add-on exposes a web-based settings page for managing which tools are available to AI assistants. Click **"Open Web UI"** on the add-on info page to access it.
32+
33+
Features:
34+
- **Enable/disable individual tools** — toggle each tool on or off
35+
- **Pin tools** — keep tools always visible when `enable_tool_search` is on
36+
- **Per-group master toggle** — enable/disable all tools in a group (HACS, System, etc.) with one click
37+
- **Search** — filter tools by name or title
38+
- **Mandatory tools**`ha_search_entities`, `ha_get_overview`, `ha_get_state`, `ha_report_issue` are always enabled and cannot be disabled
39+
- **Feature-gated tools**`ha_config_set_yaml` (requires `enable_yaml_config_editing`), filesystem tools (require `enable_filesystem_tools`), and `ha_install_mcp_tools` (requires `enable_custom_component_integration`) appear in the list with a note if their feature flag is off
40+
- **In-UI restart** — a "Restart Add-on" button appears after saving to apply changes with one click
41+
42+
**Important:** Tool configuration changes require an add-on restart to take effect. The UI will prompt you to restart after saving.
43+
44+
### Non-add-on installations
45+
46+
In Docker (`ha-mcp-web`) and standalone HTTP installations, the settings UI is mounted under your MCP secret path. Open `http://<host>:<port>/<secret_path>/settings` (the same URL prefix that protects your MCP endpoint). This keeps the auth posture consistent — anyone who can reach your MCP endpoint can also use the settings UI; anyone who can't, can't.
47+
48+
### Text-field fallback
49+
50+
If you prefer not to use the web UI (or want to set these before first start), the `disabled_tools` and `pinned_tools` options accept comma-separated tool names as seed values. On first start, the add-on creates `/data/tool_config.json` from these values. After that, the web UI is the source of truth.
51+
2352
## Updates
2453

2554
The dev channel updates automatically with every commit to master. You may receive multiple updates per day.

homeassistant-addon-dev/config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ arch:
1010
init: false
1111
startup: application
1212
boot: manual
13+
ingress: true
14+
ingress_port: 9583
15+
ingress_stream: true
1316
hassio_api: true
1417
hassio_role: default
1518
homeassistant_api: true
@@ -21,6 +24,9 @@ options:
2124
enable_skills_as_tools: true
2225
enable_tool_search: false
2326
enable_yaml_config_editing: false
27+
tool_search_max_results: 5
28+
disabled_tools: ""
29+
pinned_tools: ""
2430
schema:
2531
backup_hint: list(strong|normal|weak|auto)
2632
secret_path: str?
@@ -30,5 +36,8 @@ schema:
3036
enable_yaml_config_editing: bool?
3137
enable_filesystem_tools: bool?
3238
enable_custom_component_integration: bool?
39+
tool_search_max_results: int(2,10)?
40+
disabled_tools: str?
41+
pinned_tools: str?
3342
ports:
3443
9583/tcp: 9583

homeassistant-addon-dev/translations/en.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,22 @@ configuration:
5656
required for filesystem tools to function. Only enable if you want to
5757
allow the AI assistant to use the installer tool. Requires restart to
5858
take effect.
59+
tool_search_max_results:
60+
name: Tool search max results
61+
description: >-
62+
Maximum number of tools returned by ha_search_tools when tool
63+
search is enabled. Lower values (2-3) save context tokens but
64+
may miss relevant tools. Range: 2-10. Requires restart.
65+
disabled_tools:
66+
name: Disabled tools (text fallback)
67+
description: >-
68+
Comma-separated tool names to disable. For a visual interface,
69+
click "Open Web UI" on the addon info page. This field seeds the initial config
70+
when the web UI hasn't been used yet. Requires restart.
71+
pinned_tools:
72+
name: Pinned tools (text fallback)
73+
description: >-
74+
Comma-separated tool names to pin (always visible in tool search).
75+
For a visual interface, click "Open Web UI" on the addon info page. This field
76+
seeds the initial config when the web UI hasn't been used yet.
77+
Requires restart.

homeassistant-addon/start.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ def main() -> int:
263263
enable_yaml_config_editing = False # default
264264
enable_filesystem_tools = False # default
265265
enable_custom_component_integration = False # default
266+
tool_search_max_results = 5 # default
267+
disabled_tools_raw = "" # default
268+
pinned_tools_raw = "" # default
266269
config_read_ok = True
267270

268271
if config_file.exists():
@@ -283,6 +286,12 @@ def main() -> int:
283286
enable_filesystem_tools = raw_filesystem_tools if isinstance(raw_filesystem_tools, bool) else False
284287
raw_custom_component = config.get("enable_custom_component_integration", False)
285288
enable_custom_component_integration = raw_custom_component if isinstance(raw_custom_component, bool) else False
289+
raw_max_results = config.get("tool_search_max_results", 5)
290+
tool_search_max_results = raw_max_results if isinstance(raw_max_results, int) else 5
291+
raw_disabled = config.get("disabled_tools", "")
292+
disabled_tools_raw = raw_disabled if isinstance(raw_disabled, str) else ""
293+
raw_pinned = config.get("pinned_tools", "")
294+
pinned_tools_raw = raw_pinned if isinstance(raw_pinned, str) else ""
286295
except Exception as e:
287296
log_error(f"Failed to read config: {e}, using defaults")
288297
config_read_ok = False
@@ -323,6 +332,9 @@ def main() -> int:
323332
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
324333
os.environ["HAMCP_ENABLE_FILESYSTEM_TOOLS"] = str(enable_filesystem_tools).lower()
325334
os.environ["HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"] = str(enable_custom_component_integration).lower()
335+
os.environ["TOOL_SEARCH_MAX_RESULTS"] = str(tool_search_max_results)
336+
os.environ["DISABLED_TOOLS"] = disabled_tools_raw
337+
os.environ["PINNED_TOOLS"] = pinned_tools_raw
326338

327339
os.environ["HOMEASSISTANT_TOKEN"] = supervisor_token
328340

@@ -351,12 +363,21 @@ def main() -> int:
351363
log_info("Importing ha_mcp module...")
352364
from ha_mcp.__main__ import (
353365
StatelessSessionLogFilter,
366+
_get_server,
354367
_get_timestamped_uvicorn_log_config,
355368
mcp,
356369
register_browser_landing,
357370
)
371+
from ha_mcp.settings_ui import register_settings_routes
358372

359373
register_browser_landing(mcp, secret_path)
374+
# Mount settings UI routes both at root (for HA ingress proxy) and
375+
# under the secret path (for direct port access). See
376+
# register_settings_routes docstring for the auth model. Use the
377+
# server's actual FastMCP instance (not the _DeferredMCP wrapper)
378+
# so mypy doesn't trip over the duck-typed __getattr__ forwarding.
379+
server_instance = _get_server()
380+
register_settings_routes(server_instance.mcp, server_instance, secret_path=secret_path)
360381
logging.getLogger("mcp.server.streamable_http").addFilter(
361382
StatelessSessionLogFilter()
362383
)

src/ha_mcp/__main__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,8 +733,11 @@ def _run_http_server(transport: str, default_port: int = 8086) -> None:
733733
transport: Transport type (http or sse).
734734
default_port: Default port to use if MCP_PORT env var is not set.
735735
"""
736+
from ha_mcp.settings_ui import register_settings_routes
737+
736738
port, path = _get_http_runtime(default_port)
737739
register_browser_landing(_get_mcp(), path)
740+
register_settings_routes(_get_mcp(), _get_server(), secret_path=path)
738741

739742
_run_entrypoint(
740743
_run_http_with_graceful_shutdown(transport, port, path),
@@ -871,6 +874,9 @@ async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) ->
871874
logger.info("Server created with OAuthProxyClient")
872875
register_browser_landing(mcp, path)
873876

877+
from ha_mcp.settings_ui import register_settings_routes
878+
register_settings_routes(mcp, _server, secret_path=path)
879+
874880
tools = await mcp.list_tools()
875881
logger.info(
876882
f"Starting OAuth-enabled MCP server with {len(tools)} tools on {base_url}{path}"

src/ha_mcp/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ class Settings(BaseSettings):
110110
# files. Disabled by default; only for YAML-only features with no UI/API path.
111111
enable_yaml_config_editing: bool = Field(False, alias="ENABLE_YAML_CONFIG_EDITING")
112112

113+
# Seed values for tool visibility (comma-separated tool names).
114+
# Used as initial config when no tool_config.json exists.
115+
# The web settings UI (/settings) is the primary interface for managing these.
116+
disabled_tools: str = Field("", alias="DISABLED_TOOLS")
117+
pinned_tools: str = Field("", alias="PINNED_TOOLS")
118+
119+
# Max results returned by ha_search_tools. Pydantic enforces the
120+
# 2-10 range; the addon-dev schema also uses ``int(2,10)?`` so the
121+
# supervisor UI rejects out-of-range values before they reach env vars.
122+
tool_search_max_results: int = Field(5, ge=2, le=10, alias="TOOL_SEARCH_MAX_RESULTS")
123+
113124
@model_validator(mode="after")
114125
def _skills_dependency(self) -> "Settings":
115126
"""Auto-enable skills (resources) when skills-as-tools is on.

src/ha_mcp/server.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def __init__(
7070
self._device_tools: Any = None
7171
self._tools_registry: ToolsRegistry | None = None
7272
self._skill_tool_names: list[str] = []
73+
# Populated by _apply_settings_visibility from tool_config.json on startup
74+
self._user_pinned_tools: list[str] = []
7375

7476
# Get server name/version from settings if no client provided
7577
if not self._client_provided:
@@ -143,6 +145,11 @@ def _initialize_server(self) -> None:
143145
# Register bundled skills as MCP resources
144146
self._register_skills()
145147

148+
# Apply user-configured tool visibility (must come before keyword
149+
# enrichment / tool search so disabled tools are excluded from
150+
# search indexing too).
151+
self._apply_settings_visibility()
152+
146153
# Enrich tool descriptions with BM25 keyword boosts. Runs
147154
# unconditionally so Claude's native deferred-tool search
148155
# (claude.ai) benefits even when ENABLE_TOOL_SEARCH is off.
@@ -313,6 +320,25 @@ def _build_skill_block(self, skill_name: str, main_file: Path) -> str | None:
313320

314321
return f"\n### Skill: {skill_name} ({uri})\n{description.strip()}"
315322

323+
def _apply_settings_visibility(self) -> None:
324+
"""Apply persisted tool visibility from ``tool_config.json``.
325+
326+
Reads the saved enable/disable/pin state and applies it to the
327+
FastMCP instance via ``apply_tool_visibility``. HTTP routes for
328+
the settings UI are registered separately by entry-point callers
329+
(start.py / main_web) so they can be mounted under the secret
330+
path; that keeps the routes inert in stdio mode and behind the
331+
same auth posture as the MCP endpoint in HTTP mode.
332+
"""
333+
from .settings_ui import apply_tool_visibility, load_tool_config
334+
335+
config = load_tool_config(self.settings)
336+
if config:
337+
pinned = apply_tool_visibility(self.mcp, config, self.settings)
338+
if pinned:
339+
self._user_pinned_tools = list(pinned)
340+
logger.info("Applied persisted tool config (%d entries)", len(config.get("tools", {})))
341+
316342
# Tools pinned outside the search transform for individual permission gating.
317343
# These are always visible in list_tools() regardless of search transform.
318344
_PINNED_TOOLS: ClassVar[list[str]] = list(DEFAULT_PINNED_TOOLS)
@@ -487,8 +513,9 @@ def _apply_tool_search(self) -> None:
487513
)
488514
return
489515

490-
# Build the always_visible list
516+
# Build the always_visible list: defaults + user-configured pins
491517
pinned = list(self._PINNED_TOOLS)
518+
pinned.extend(self._user_pinned_tools)
492519

493520
# Pin ResourcesAsTools and skill guidance tools if skills-as-tools is enabled
494521
if self.settings.enable_skills_as_tools:
@@ -513,12 +540,16 @@ def _apply_tool_search(self) -> None:
513540
try:
514541
self.mcp.add_transform(
515542
CategorizedSearchTransform(
516-
max_results=5,
543+
max_results=self.settings.tool_search_max_results,
517544
always_visible=pinned,
518545
search_tool_description=description,
519546
)
520547
)
521-
logger.info("Tool search transform applied (%d pinned tools)", len(pinned))
548+
logger.info(
549+
"Tool search transform applied (%d pinned tools, max_results=%d)",
550+
len(pinned),
551+
self.settings.tool_search_max_results,
552+
)
522553
except Exception:
523554
logger.exception("Failed to apply tool search transform")
524555

0 commit comments

Comments
 (0)