Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4660543
feat: web-based settings UI for per-tool enable/disable/pin
kingpanther13 Apr 12, 2026
0089c27
fix: address CI failures and Gemini review comments
kingpanther13 Apr 12, 2026
915cd66
feat: add ingress config and text field fallbacks for addon
kingpanther13 Apr 12, 2026
d8623e6
fix: add ingress_stream, serve settings at root for ingress
kingpanther13 Apr 12, 2026
59c1eda
fix: add ingress + text fields to addon-dev config
kingpanther13 Apr 12, 2026
041f1b6
fix: revert homeassistant-addon/ to master (release pipeline handles it)
kingpanther13 Apr 12, 2026
1da91b2
fix: use live FastMCP list_tools() and relative fetch URLs
kingpanther13 Apr 13, 2026
0b5e8db
feat: dual toggles, feature-gated stubs, tool_search_max_results, gro…
kingpanther13 Apr 13, 2026
ad46b8a
fix: remove unused type: ignore on _list_tools
kingpanther13 Apr 13, 2026
c3beb1e
feat: preserve group open state, add per-group master toggle
kingpanther13 Apr 13, 2026
fe8440a
fix: require restart to apply tool visibility changes
kingpanther13 Apr 13, 2026
93a3e52
feat: add in-UI restart button using Supervisor API
kingpanther13 Apr 13, 2026
1b05ca9
fix: treat dropped connection as success in restart handler
kingpanther13 Apr 13, 2026
d8cc976
docs: document settings UI in addon DOCS and .env.example
kingpanther13 Apr 13, 2026
9c6e8de
fix: address Gemini review feedback
kingpanther13 Apr 13, 2026
c44b323
fix: remove unnecessary type annotation quotes (UP037)
kingpanther13 Apr 13, 2026
72cd3ca
Merge upstream/master into feat/settings-ui + address Patch76 review
kingpanther13 May 2, 2026
3477a39
fix: pass real FastMCP to register_settings_routes (mypy)
kingpanther13 May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ LOG_LEVEL=INFO
# top-level keys in configuration.yaml and packages/*.yaml.
# ENABLE_YAML_CONFIG_EDITING=false

# Tool visibility seed values (comma-separated tool names). On first start
# these are written to tool_config.json; after that the web settings UI at
# http://<server>/settings is the source of truth. See DOCS.md for details.
# DISABLED_TOOLS=
# PINNED_TOOLS=

# Max results from ha_search_tools (2-10, default 5).
# TOOL_SEARCH_MAX_RESULTS=5

# Optional: MCP Server Configuration
MCP_SERVER_NAME=ha-mcp
# MCP_SERVER_VERSION defaults to the package version (e.g. 6.7.2)
# MCP_SERVER_VERSION defaults to the package version (e.g. 6.7.2)
29 changes: 29 additions & 0 deletions homeassistant-addon-dev/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,41 @@ The dev add-on uses the same configuration as the stable version. See the main a
|--------|-------------|---------|
| `backup_hint` | Backup strength preference | `normal` |
| `secret_path` | Custom secret path (optional) | auto-generated |
| `enable_skills` | Serve bundled HA best-practice skills as MCP resources | `true` |
| `enable_skills_as_tools` | Expose skills via list_resources/read_resource tools | `true` |
| `enable_tool_search` | Replace full tool catalog with search-based discovery (~46K → ~5K tokens) | `false` |
| `enable_yaml_config_editing` *(beta)* | Enables `ha_config_set_yaml` for editing `configuration.yaml` directly. Requires `ha_mcp_tools` custom component. | `false` |
| `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` |
| `enable_custom_component_integration` *(beta)* | Enables `ha_install_mcp_tools` installer tool for the `ha_mcp_tools` custom component. | `false` |
| `tool_search_max_results` | Max results from `ha_search_tools` (range 2-10) | `5` |
| `disabled_tools` | Comma-separated list of tool names to disable (seed value; web UI is primary) | empty |
| `pinned_tools` | Comma-separated list of tool names to pin when tool search is enabled (seed value; web UI is primary) | empty |

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.

## Tool Settings Web UI

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.

Features:
- **Enable/disable individual tools** — toggle each tool on or off
- **Pin tools** — keep tools always visible when `enable_tool_search` is on
- **Per-group master toggle** — enable/disable all tools in a group (HACS, System, etc.) with one click
- **Search** — filter tools by name or title
- **Mandatory tools** — `ha_search_entities`, `ha_get_overview`, `ha_get_state`, `ha_report_issue` are always enabled and cannot be disabled
- **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
- **In-UI restart** — a "Restart Add-on" button appears after saving to apply changes with one click

**Important:** Tool configuration changes require an add-on restart to take effect. The UI will prompt you to restart after saving.

### Non-add-on installations

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.

### Text-field fallback

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.

## Updates

The dev channel updates automatically with every commit to master. You may receive multiple updates per day.
Expand Down
9 changes: 9 additions & 0 deletions homeassistant-addon-dev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ arch:
init: false
startup: application
boot: manual
ingress: true
ingress_port: 9583
ingress_stream: true
hassio_api: true
hassio_role: default
homeassistant_api: true
Expand All @@ -21,6 +24,9 @@ options:
enable_skills_as_tools: true
enable_tool_search: false
enable_yaml_config_editing: false
tool_search_max_results: 5
disabled_tools: ""
pinned_tools: ""
schema:
backup_hint: list(strong|normal|weak|auto)
secret_path: str?
Expand All @@ -30,5 +36,8 @@ schema:
enable_yaml_config_editing: bool?
enable_filesystem_tools: bool?
enable_custom_component_integration: bool?
tool_search_max_results: int(2,10)?
disabled_tools: str?
pinned_tools: str?
ports:
9583/tcp: 9583
19 changes: 19 additions & 0 deletions homeassistant-addon-dev/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,22 @@ configuration:
required for filesystem tools to function. Only enable if you want to
allow the AI assistant to use the installer tool. Requires restart to
take effect.
tool_search_max_results:
name: Tool search max results
description: >-
Maximum number of tools returned by ha_search_tools when tool
search is enabled. Lower values (2-3) save context tokens but
may miss relevant tools. Range: 2-10. Requires restart.
disabled_tools:
name: Disabled tools (text fallback)
description: >-
Comma-separated tool names to disable. For a visual interface,
click "Open Web UI" on the addon info page. This field seeds the initial config
when the web UI hasn't been used yet. Requires restart.
pinned_tools:
name: Pinned tools (text fallback)
description: >-
Comma-separated tool names to pin (always visible in tool search).
For a visual interface, click "Open Web UI" on the addon info page. This field
seeds the initial config when the web UI hasn't been used yet.
Requires restart.
21 changes: 21 additions & 0 deletions homeassistant-addon/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ def main() -> int:
enable_yaml_config_editing = False # default
enable_filesystem_tools = False # default
enable_custom_component_integration = False # default
tool_search_max_results = 5 # default
disabled_tools_raw = "" # default
pinned_tools_raw = "" # default
config_read_ok = True

if config_file.exists():
Expand All @@ -283,6 +286,12 @@ def main() -> int:
enable_filesystem_tools = raw_filesystem_tools if isinstance(raw_filesystem_tools, bool) else False
raw_custom_component = config.get("enable_custom_component_integration", False)
enable_custom_component_integration = raw_custom_component if isinstance(raw_custom_component, bool) else False
raw_max_results = config.get("tool_search_max_results", 5)
tool_search_max_results = raw_max_results if isinstance(raw_max_results, int) else 5
raw_disabled = config.get("disabled_tools", "")
disabled_tools_raw = raw_disabled if isinstance(raw_disabled, str) else ""
raw_pinned = config.get("pinned_tools", "")
pinned_tools_raw = raw_pinned if isinstance(raw_pinned, str) else ""
except Exception as e:
log_error(f"Failed to read config: {e}, using defaults")
config_read_ok = False
Expand Down Expand Up @@ -323,6 +332,9 @@ def main() -> int:
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
os.environ["HAMCP_ENABLE_FILESYSTEM_TOOLS"] = str(enable_filesystem_tools).lower()
os.environ["HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"] = str(enable_custom_component_integration).lower()
os.environ["TOOL_SEARCH_MAX_RESULTS"] = str(tool_search_max_results)
os.environ["DISABLED_TOOLS"] = disabled_tools_raw
os.environ["PINNED_TOOLS"] = pinned_tools_raw

os.environ["HOMEASSISTANT_TOKEN"] = supervisor_token

Expand Down Expand Up @@ -351,12 +363,21 @@ def main() -> int:
log_info("Importing ha_mcp module...")
from ha_mcp.__main__ import (
StatelessSessionLogFilter,
_get_server,
_get_timestamped_uvicorn_log_config,
mcp,
register_browser_landing,
)
from ha_mcp.settings_ui import register_settings_routes

register_browser_landing(mcp, secret_path)
# Mount settings UI routes both at root (for HA ingress proxy) and
# under the secret path (for direct port access). See
# register_settings_routes docstring for the auth model. Use the
# server's actual FastMCP instance (not the _DeferredMCP wrapper)
# so mypy doesn't trip over the duck-typed __getattr__ forwarding.
server_instance = _get_server()
register_settings_routes(server_instance.mcp, server_instance, secret_path=secret_path)
logging.getLogger("mcp.server.streamable_http").addFilter(
StatelessSessionLogFilter()
)
Expand Down
6 changes: 6 additions & 0 deletions src/ha_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,11 @@ def _run_http_server(transport: str, default_port: int = 8086) -> None:
transport: Transport type (http or sse).
default_port: Default port to use if MCP_PORT env var is not set.
"""
from ha_mcp.settings_ui import register_settings_routes

port, path = _get_http_runtime(default_port)
register_browser_landing(_get_mcp(), path)
register_settings_routes(_get_mcp(), _get_server(), secret_path=path)

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

from ha_mcp.settings_ui import register_settings_routes
register_settings_routes(mcp, _server, secret_path=path)

tools = await mcp.list_tools()
logger.info(
f"Starting OAuth-enabled MCP server with {len(tools)} tools on {base_url}{path}"
Expand Down
11 changes: 11 additions & 0 deletions src/ha_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ class Settings(BaseSettings):
# files. Disabled by default; only for YAML-only features with no UI/API path.
enable_yaml_config_editing: bool = Field(False, alias="ENABLE_YAML_CONFIG_EDITING")

# Seed values for tool visibility (comma-separated tool names).
# Used as initial config when no tool_config.json exists.
# The web settings UI (/settings) is the primary interface for managing these.
disabled_tools: str = Field("", alias="DISABLED_TOOLS")
pinned_tools: str = Field("", alias="PINNED_TOOLS")

# Max results returned by ha_search_tools. Pydantic enforces the
# 2-10 range; the addon-dev schema also uses ``int(2,10)?`` so the
# supervisor UI rejects out-of-range values before they reach env vars.
tool_search_max_results: int = Field(5, ge=2, le=10, alias="TOOL_SEARCH_MAX_RESULTS")

@model_validator(mode="after")
def _skills_dependency(self) -> "Settings":
"""Auto-enable skills (resources) when skills-as-tools is on.
Expand Down
37 changes: 34 additions & 3 deletions src/ha_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ def __init__(
self._device_tools: Any = None
self._tools_registry: ToolsRegistry | None = None
self._skill_tool_names: list[str] = []
# Populated by _apply_settings_visibility from tool_config.json on startup
self._user_pinned_tools: list[str] = []

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

# Apply user-configured tool visibility (must come before keyword
# enrichment / tool search so disabled tools are excluded from
# search indexing too).
self._apply_settings_visibility()

# Enrich tool descriptions with BM25 keyword boosts. Runs
# unconditionally so Claude's native deferred-tool search
# (claude.ai) benefits even when ENABLE_TOOL_SEARCH is off.
Expand Down Expand Up @@ -313,6 +320,25 @@ def _build_skill_block(self, skill_name: str, main_file: Path) -> str | None:

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

def _apply_settings_visibility(self) -> None:
"""Apply persisted tool visibility from ``tool_config.json``.

Reads the saved enable/disable/pin state and applies it to the
FastMCP instance via ``apply_tool_visibility``. HTTP routes for
the settings UI are registered separately by entry-point callers
(start.py / main_web) so they can be mounted under the secret
path; that keeps the routes inert in stdio mode and behind the
same auth posture as the MCP endpoint in HTTP mode.
"""
from .settings_ui import apply_tool_visibility, load_tool_config

config = load_tool_config(self.settings)
if config:
pinned = apply_tool_visibility(self.mcp, config, self.settings)
if pinned:
self._user_pinned_tools = list(pinned)
logger.info("Applied persisted tool config (%d entries)", len(config.get("tools", {})))

# Tools pinned outside the search transform for individual permission gating.
# These are always visible in list_tools() regardless of search transform.
_PINNED_TOOLS: ClassVar[list[str]] = list(DEFAULT_PINNED_TOOLS)
Expand Down Expand Up @@ -487,8 +513,9 @@ def _apply_tool_search(self) -> None:
)
return

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

# Pin ResourcesAsTools and skill guidance tools if skills-as-tools is enabled
if self.settings.enable_skills_as_tools:
Expand All @@ -513,12 +540,16 @@ def _apply_tool_search(self) -> None:
try:
self.mcp.add_transform(
CategorizedSearchTransform(
max_results=5,
max_results=self.settings.tool_search_max_results,
always_visible=pinned,
search_tool_description=description,
)
)
logger.info("Tool search transform applied (%d pinned tools)", len(pinned))
logger.info(
"Tool search transform applied (%d pinned tools, max_results=%d)",
len(pinned),
self.settings.tool_search_max_results,
)
except Exception:
logger.exception("Failed to apply tool search transform")

Expand Down
Loading