Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
18 changes: 12 additions & 6 deletions homeassistant-addon-dev/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ configuration:
enable_yaml_config_editing:
name: Enable YAML config editing
description: >-
Allow AI assistants to add, replace, or remove top-level keys in
configuration.yaml and packages/*.yaml. Only whitelisted keys are
allowed (e.g., template, sensor, command_line, mqtt). Core keys
like homeassistant, http, and recorder are blocked. A backup is
created before every edit. Use for YAML-only features that have no
UI or API alternative. Requires restart to take effect.
The only ha-mcp tool gated behind a toggle, disabled by default.
Intended for operators comfortable editing configuration.yaml
directly — a broken edit can prevent Home Assistant from starting,
and recovery may require SSH access to restore from the automatic
backup. AI assistants also sometimes select this tool when a
dedicated tool (automations, scripts, scenes, helpers, template
sensors) would be correct. Allows AI assistants to add, replace,
or remove top-level keys in configuration.yaml and packages/*.yaml.
Only whitelisted keys are allowed (e.g., template, sensor,
command_line, mqtt); core keys like homeassistant, http, and
recorder are blocked. A backup is created before every edit.
Requires restart to take effect.
2 changes: 1 addition & 1 deletion homeassistant-addon/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ The add-on provides 86+ MCP tools for controlling Home Assistant:
- `ha_backup_create` — Create a fast Home Assistant backup (local only).
- `ha_backup_restore` — Restore Home Assistant from a backup (LAST RESORT - use with extreme caution).
- `ha_check_config` — Check Home Assistant configuration for errors.
- `ha_config_set_yaml` — Add, replace, or remove a top-level key in configuration.yaml or package files.
- `ha_config_set_yaml` — Update raw YAML configuration in configuration.yaml or packages/*.yaml (LAST RESORT).
- `ha_get_system_health` — Get Home Assistant system health, including Zigbee (ZHA) and Z-Wave JS network diagnostics.
- `ha_get_updates` — Get update information - list all updates or get details for a specific one.
- `ha_reload_core` — Reload Home Assistant configuration without full restart.
Expand Down
6 changes: 3 additions & 3 deletions site/src/data/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -2414,12 +2414,12 @@
},
{
"name": "ha_config_set_yaml",
"title": "Set YAML Config",
"description": "Add, replace, or remove a top-level key in configuration.yaml or package files.\n\nIMPORTANT: Only use when NO UI or API alternative exists. Prefer:\n- Template sensors -> ha_config_set_helper (Template Helper)\n- Automations -> ha_config_set_automation\n- Scripts -> ha_config_set_script\n- Input helpers -> ha_config_set_helper\n- Scenes -> ha_config_set_scene\n\nThis tool is for YAML-only features with no UI/API path (e.g.,\ncommand_line sensors, platform-based MQTT sensors in YAML, rest\nsensors defined in packages).\n\nSafeguards: file backup, YAML validation, top-level key whitelist,\npath traversal blocking, post-edit config check.\n\nIMPORTANT: Check 'post_action' in the response. Most keys require\na full HA restart ('restart_required'). Only template, mqtt, and\ngroup support reload ('reload_available' with 'reload_service').\n\nPreserves YAML comments on sibling keys, file-level comments,\nand Home Assistant tags (!include, !secret, etc.). The 'replace' action\nsubstitutes the subtree as-is, so comments from the old subtree\ndo not carry over.",
"title": "Raw YAML Config Edit",
"description": "Update raw YAML configuration in configuration.yaml or packages/*.yaml (LAST RESORT).\n\n**WARNING:** Destructive, disabled by default. Dedicated tools exist for\nalmost every use case and should be preferred:\n\n- Template sensors (state-based or trigger-based) ->\n ha_set_config_entry_helper(helper_type='template')\n- Automations -> ha_config_set_automation\n- Scripts -> ha_config_set_script\n- Scenes -> ha_config_set_scene\n- Input helpers -> ha_config_set_helper\n- Groups, min/max, threshold, derivative, statistics, utility_meter,\n trend, filter, switch_as_x -> ha_set_config_entry_helper\n\nIntended for YAML-only integrations with no config-flow or API\nequivalent (command_line, rest, shell_command, notify platforms).\nCheck ``post_action`` in the response: most keys need a full HA\nrestart; template, mqtt, and group support reload. Preserves YAML\ncomments and HA tags (``!include``, ``!secret``) on round-trip;\n``replace`` swaps the subtree as-is.\n\nFor detailed routing guidance, use ha_get_skill_home_assistant_best_practices.",
"inputSchema": {
"properties": {
"yaml_path": {
"type": "Annotated[str, Field(description=\"Top-level YAML key to modify (e.g., 'template', 'sensor', 'input_boolean'). Only whitelisted keys are allowed.\")]"
"type": "Annotated[str, Field(description=\"Top-level YAML key to modify. Only a narrow allowlist of YAML-only integration keys is accepted (e.g., 'command_line', 'rest', 'shell_command', 'notify'). Not for template sensors (use ha_set_config_entry_helper), automations, scripts, scenes, or input_* helpers — those have dedicated tools.\")]"
},
"action": {
"type": "Annotated[str, Field(description=\"Action to perform: 'add' (insert/merge content under key), 'replace' (overwrite key with new content), or 'remove' (delete the key entirely).\")]"
Expand Down
43 changes: 22 additions & 21 deletions src/ha_mcp/tools/tools_yaml_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def register_yaml_config_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
annotations={
"destructiveHint": True,
"idempotentHint": False,
"title": "Set YAML Config",
"title": "Raw YAML Config Edit",
},
)
@log_tool_usage
Expand All @@ -57,8 +57,11 @@ async def ha_config_set_yaml(
str,
Field(
description=(
"Top-level YAML key to modify (e.g., 'template', 'sensor', "
"'input_boolean'). Only whitelisted keys are allowed."
"Top-level YAML key to modify. Only a narrow allowlist of "
"YAML-only integration keys is accepted (e.g., 'command_line', "
"'rest', 'shell_command', 'notify'). Not for template sensors "
"(use ha_set_config_entry_helper), automations, scripts, "
"scenes, or input_* helpers — those have dedicated tools."
),
),
],
Expand Down Expand Up @@ -103,30 +106,28 @@ async def ha_config_set_yaml(
),
] = True,
) -> dict[str, Any]:
"""Add, replace, or remove a top-level key in configuration.yaml or package files.
"""Update raw YAML configuration in configuration.yaml or packages/*.yaml (LAST RESORT).

IMPORTANT: Only use when NO UI or API alternative exists. Prefer:
- Template sensors -> ha_config_set_helper (Template Helper)
**WARNING:** Destructive, disabled by default. Dedicated tools exist for
almost every use case and should be preferred:

- Template sensors (state-based or trigger-based) ->
ha_set_config_entry_helper(helper_type='template')
- Automations -> ha_config_set_automation
- Scripts -> ha_config_set_script
- Input helpers -> ha_config_set_helper
- Scenes -> ha_config_set_scene
- Input helpers -> ha_config_set_helper
- Groups, min/max, threshold, derivative, statistics, utility_meter,
trend, filter, switch_as_x -> ha_set_config_entry_helper

This tool is for YAML-only features with no UI/API path (e.g.,
command_line sensors, platform-based MQTT sensors in YAML, rest
sensors defined in packages).

Safeguards: file backup, YAML validation, top-level key whitelist,
path traversal blocking, post-edit config check.

IMPORTANT: Check 'post_action' in the response. Most keys require
a full HA restart ('restart_required'). Only template, mqtt, and
group support reload ('reload_available' with 'reload_service').
Intended for YAML-only integrations with no config-flow or API
equivalent (command_line, rest, shell_command, notify platforms).
Check ``post_action`` in the response: most keys need a full HA
restart; template, mqtt, and group support reload. Preserves YAML
comments and HA tags (``!include``, ``!secret``) on round-trip;
``replace`` swaps the subtree as-is.

Preserves YAML comments on sibling keys, file-level comments,
and Home Assistant tags (!include, !secret, etc.). The 'replace' action
substitutes the subtree as-is, so comments from the old subtree
do not carry over.
For detailed routing guidance, use ha_get_skill_home_assistant_best_practices.
"""
try:
# Validate action
Expand Down
52 changes: 15 additions & 37 deletions tests/src/e2e/workflows/filesystem/test_yaml_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,7 @@ async def test_null_content_rejected(self, mcp_client_with_yaml_config):
},
)
inner = data
assert inner.get("success") is False, (
f"Null content should be rejected: {data}"
)
assert inner.get("success") is False, f"Null content should be rejected: {data}"
logger.info("Correctly rejected null content")

async def test_invalid_yaml_rejected(self, mcp_client_with_yaml_config):
Expand All @@ -224,9 +222,7 @@ async def test_invalid_yaml_rejected(self, mcp_client_with_yaml_config):
},
)
inner = data
assert inner.get("success") is False, (
f"Invalid YAML should be rejected: {data}"
)
assert inner.get("success") is False, f"Invalid YAML should be rejected: {data}"
logger.info("Correctly rejected invalid YAML")

async def test_missing_content_for_add(self, mcp_client_with_yaml_config):
Expand All @@ -242,9 +238,7 @@ async def test_missing_content_for_add(self, mcp_client_with_yaml_config):
},
)
inner = data
assert inner.get("success") is False, (
f"Missing content should fail: {data}"
)
assert inner.get("success") is False, f"Missing content should fail: {data}"
logger.info("Correctly rejected missing content")


Expand Down Expand Up @@ -406,9 +400,7 @@ async def test_add_type_mismatch_errors(self, mcp_client_with_yaml_config):
},
)
inner = data
assert inner.get("success") is False, (
f"Type mismatch should error: {data}"
)
assert inner.get("success") is False, f"Type mismatch should error: {data}"
assert "type mismatch" in inner.get("error", "").lower()
logger.info("Correctly errored on type mismatch")

Expand Down Expand Up @@ -453,9 +445,7 @@ async def test_backup_created(self, mcp_client_with_yaml_config):
)
inner = data
assert inner.get("success") is True, f"Replace should succeed: {data}"
assert inner.get("backup_path"), (
f"Backup path should be present: {data}"
)
assert inner.get("backup_path"), f"Backup path should be present: {data}"
assert "yaml_backups" in inner.get("backup_path", "")
logger.info(f"Backup created at: {inner.get('backup_path')}")

Expand Down Expand Up @@ -512,7 +502,9 @@ async def test_post_action_reload_for_template(self, mcp_client_with_yaml_config
f"reload_service={inner.get('reload_service')}"
)

async def test_post_action_restart_for_shell_command(self, mcp_client_with_yaml_config):
async def test_post_action_restart_for_shell_command(
self, mcp_client_with_yaml_config
):
"""shell_command key should return post_action=restart_required."""

async with MCPAssertions(mcp_client_with_yaml_config) as mcp:
Expand Down Expand Up @@ -546,9 +538,7 @@ async def test_post_action_restart_for_shell_command(self, mcp_client_with_yaml_
class TestYamlConfigCommentPreservation:
"""Test that YAML comments and HA tags (e.g. !secret) survive edits."""

async def test_comments_preserved_after_add(
self, mcp_client_with_yaml_config
):
async def test_comments_preserved_after_add(self, mcp_client_with_yaml_config):
"""Comments and !secret tags in one key survive when a different key is added."""

test_file = "packages/_e2e_test_comments.yaml"
Expand Down Expand Up @@ -596,9 +586,7 @@ async def test_comments_preserved_after_add(
{"path": test_file},
)
if read_data.get("success") is not True:
pytest.skip(
f"ha_read_file not functional for packages: {read_data}"
)
pytest.skip(f"ha_read_file not functional for packages: {read_data}")

content = read_data.get("content", "")
assert "# Sensor configuration" in content, (
Expand All @@ -607,26 +595,18 @@ async def test_comments_preserved_after_add(
assert "# inline comment" in content, (
f"Inline comment lost after add: {content!r}"
)
assert "!secret" in content, (
f"!secret tag lost after add: {content!r}"
)
assert "!secret" in content, f"!secret tag lost after add: {content!r}"
assert "sensor_api_key" in content, (
f"Secret key name lost after add: {content!r}"
)
logger.info(
"Comments and !secret tags preserved after adding a second key"
)
logger.info("Comments and !secret tags preserved after adding a second key")

async def test_ha_tags_preserved_after_edit(
self, mcp_client_with_yaml_config
):
async def test_ha_tags_preserved_after_edit(self, mcp_client_with_yaml_config):
"""HA-specific YAML tags like !secret must survive when a different key is edited."""

test_file = "packages/_e2e_test_tags.yaml"
initial_content = (
"api_key: !secret my_api_key\n"
"name: Tagged Sensor\n"
"state: 'active'"
"api_key: !secret my_api_key\nname: Tagged Sensor\nstate: 'active'"
)

async with MCPAssertions(mcp_client_with_yaml_config) as mcp:
Expand Down Expand Up @@ -665,9 +645,7 @@ async def test_ha_tags_preserved_after_edit(
{"path": test_file},
)
if read_data.get("success") is not True:
pytest.skip(
f"ha_read_file not functional for packages: {read_data}"
)
pytest.skip(f"ha_read_file not functional for packages: {read_data}")

content = read_data.get("content", "")
assert "!secret" in content, (
Expand Down