Skip to content
Merged
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<!-- mcp-name: io.github.homeassistant-ai/ha-mcp -->

<p align="center">
<img src="https://img.shields.io/badge/tools-92-blue" alt="95+ Tools">
<img src="https://img.shields.io/badge/tools-93-blue" alt="95+ Tools">
<a href="https://github.com/homeassistant-ai/ha-mcp/releases"><img src="https://img.shields.io/github/v/release/homeassistant-ai/ha-mcp" alt="Release"></a>
<a href="https://github.com/homeassistant-ai/ha-mcp/actions/workflows/e2e-tests.yml"><img src="https://img.shields.io/github/actions/workflow/status/homeassistant-ai/ha-mcp/e2e-tests.yml?branch=master&label=E2E%20Tests" alt="E2E Tests"></a>
<a href="LICENSE.md"><img src="https://img.shields.io/github/license/homeassistant-ai/ha-mcp.svg" alt="License"></a>
Expand Down Expand Up @@ -131,7 +131,7 @@ Spend less time configuring, more time enjoying your smart home.
<details>
<!-- TOOLS_TABLE_START -->

<summary><b>Complete Tool List (92 tools)</b></summary>
<summary><b>Complete Tool List (93 tools)</b></summary>

| Category | Tools |
|----------|-------|
Expand All @@ -143,7 +143,7 @@ Spend less time configuring, more time enjoying your smart home.
| **Camera** | `ha_get_camera_image` |
| **Dashboards** | `ha_config_delete_dashboard_resource`, `ha_config_delete_dashboard`, `ha_config_get_dashboard`, `ha_config_list_dashboard_resources`, `ha_config_set_dashboard_resource`, `ha_config_set_dashboard`, `ha_dashboard_find_card` |
| **Device Registry** | `ha_get_device`, `ha_remove_device`, `ha_rename_entity`, `ha_update_device` |
| **Entity Registry** | `ha_get_entity_exposure`, `ha_get_entity`, `ha_set_entity` |
| **Entity Registry** | `ha_get_entity_exposure`, `ha_get_entity`, `ha_remove_entity`, `ha_set_entity` |
| **Files** | `ha_delete_file`, `ha_list_files`, `ha_read_file`, `ha_write_file` |
| **Groups** | `ha_config_list_groups`, `ha_config_remove_group`, `ha_config_set_group` |
| **HACS** | `ha_hacs_add_repository`, `ha_hacs_download`, `ha_hacs_info`, `ha_hacs_list_installed`, `ha_hacs_repository_info`, `ha_hacs_search` |
Expand Down
23 changes: 23 additions & 0 deletions site/src/data/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,29 @@
],
"source_file": "tools_voice_assistant.py"
},
{
"name": "ha_remove_entity",
"title": "Remove Entity",
"description": "Remove an entity from the Home Assistant entity registry.\n\nPermanently removes the entity registration from Home Assistant.\nThe entity will no longer appear in the UI or be available to automations.\n\nWARNING: This permanently removes the entity registration.\n- Use only for orphaned or stale entity entries\n- If the underlying device or integration is still active, the entity\n may be re-added automatically on the next HA restart or reload\n- This action cannot be undone without restoring from backup\n\nEXAMPLES:\n- Remove orphaned sensor: ha_remove_entity(\"sensor.old_temperature\")\n- Remove stale helper entry: ha_remove_entity(\"input_boolean.deleted_helper\")\n\nNOTE: For most use cases, consider disabling instead:\nha_set_entity(entity_id=\"sensor.old\", enabled=False)\n\nRELATED TOOLS:\n- ha_search_entities: Find entities to verify the entity_id before removing\n- ha_get_entity: Check entity details before removal",
"inputSchema": {
"properties": {
"entity_id": {
"type": "Annotated[str, Field(description=\"Entity ID to remove from the entity registry (e.g., 'sensor.old_temperature'). This permanently removes the entity registration.\")]"
}
},
"required": [
"entity_id"
]
},
"annotations": {
"destructiveHint": true,
"idempotentHint": false
Comment thread
Patch76 marked this conversation as resolved.
Outdated
},
"tags": [
"Entity Registry"
],
"source_file": "tools_entities.py"
},
{
"name": "ha_set_entity",
"title": "Set Entity",
Expand Down
77 changes: 77 additions & 0 deletions src/ha_mcp/tools/tools_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,3 +941,80 @@ async def _fetch_entity(eid: str) -> dict[str, Any]:
"entity_id": entity_id if isinstance(entity_id, str) else entity_ids
},
)

@mcp.tool(
tags={"Entity Registry"},
annotations={
"destructiveHint": True,
"idempotentHint": False,
Comment thread
Patch76 marked this conversation as resolved.
Outdated
Comment thread
Patch76 marked this conversation as resolved.
Outdated
"title": "Remove Entity",
},
)
@log_tool_usage
async def ha_remove_entity(
entity_id: Annotated[
str,
Field(
description=(
"Entity ID to remove from the entity registry "
"(e.g., 'sensor.old_temperature'). "
"This permanently removes the entity registration."
)
),
],
) -> dict[str, Any]:
"""Remove an entity from the Home Assistant entity registry.

Permanently removes the entity registration from Home Assistant.
The entity will no longer appear in the UI or be available to automations.

WARNING: This permanently removes the entity registration.
- Use only for orphaned or stale entity entries
- If the underlying device or integration is still active, the entity
may be re-added automatically on the next HA restart or reload
- This action cannot be undone without restoring from backup

EXAMPLES:
- Remove orphaned sensor: ha_remove_entity("sensor.old_temperature")
- Remove stale helper entry: ha_remove_entity("input_boolean.deleted_helper")

NOTE: For most use cases, consider disabling instead:
ha_set_entity(entity_id="sensor.old", enabled=False)

RELATED TOOLS:
- ha_search_entities: Find entities to verify the entity_id before removing
- ha_get_entity: Check entity details before removal
"""
try:
result = await client.send_websocket_message(
{"type": "config/entity_registry/remove", "entity_id": entity_id}
)

if not result.get("success"):
error_msg = str(result.get("error", "Unknown error"))
Comment thread
Patch76 marked this conversation as resolved.
Outdated
if "not found" in error_msg.lower():
raise_tool_error(
create_error_response(
ErrorCode.ENTITY_NOT_FOUND,
f"Entity '{entity_id}' not found in registry",
context={"entity_id": entity_id},
)
Comment thread
Patch76 marked this conversation as resolved.
)
raise_tool_error(
create_error_response(
ErrorCode.SERVICE_CALL_FAILED,
f"Failed to remove entity '{entity_id}': {error_msg}",
context={"entity_id": entity_id},
)
)
Comment thread
Patch76 marked this conversation as resolved.
Comment thread
Patch76 marked this conversation as resolved.

return {"success": True, "entity_id": entity_id}

except ToolError:
raise
except Exception as e:
logger.error(f"Error removing entity: {e}")
Comment thread
Patch76 marked this conversation as resolved.
Outdated
exception_to_structured_error(
e,
context={"entity_id": entity_id},
)
27 changes: 27 additions & 0 deletions tests/src/e2e/workflows/entities/test_entity_remove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""E2E tests for ha_remove_entity tool."""

import logging

import pytest

from tests.src.e2e.utilities.assertions import safe_call_tool

logger = logging.getLogger(__name__)


@pytest.mark.asyncio
@pytest.mark.registry
class TestEntityRemove:
"""Test ha_remove_entity tool."""

async def test_remove_entity_nonexistent(self, mcp_client):
"""Removing a non-existent entity should fail gracefully."""
data = await safe_call_tool(
mcp_client,
"ha_remove_entity",
{"entity_id": "sensor.definitely_not_real_12345"},
)
assert not data.get("success"), (
f"Expected failure for non-existent entity, got: {data}"
)
logger.info("Non-existent entity removal error handling verified")
Comment thread
Patch76 marked this conversation as resolved.
93 changes: 93 additions & 0 deletions tests/src/unit/test_tools_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -1229,3 +1229,96 @@ async def test_disable_other_domain_allowed(self, set_entity_tool, mock_client):

result = await set_entity_tool(entity_id="sensor.temperature", enabled=False)
assert result["success"] is True


class TestHaRemoveEntity:
"""Test ha_remove_entity tool."""

@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server."""
mcp = MagicMock()
self.registered_tools = {}

def tool_decorator(*args, **kwargs):
def wrapper(func):
self.registered_tools[func.__name__] = func
return func
return wrapper

mcp.tool = tool_decorator
return mcp

@pytest.fixture
def mock_client(self):
"""Create a mock Home Assistant client."""
client = MagicMock()
client.send_websocket_message = AsyncMock()
return client

@pytest.fixture
def remove_entity_tool(self, mock_mcp, mock_client):
"""Register tools and return the ha_remove_entity function."""
register_entity_tools(mock_mcp, mock_client)
return self.registered_tools["ha_remove_entity"]

@pytest.mark.asyncio
async def test_remove_entity_success(self, remove_entity_tool, mock_client):
"""Successfully removing an entity should return success with entity_id."""
mock_client.send_websocket_message = AsyncMock(
return_value={"success": True, "result": None}
)
entity_id = "input_boolean.test_entity"

result = await remove_entity_tool(entity_id=entity_id)

assert result["success"] is True
assert result["entity_id"] == entity_id

# Verify correct WebSocket message type and payload
call_args = mock_client.send_websocket_message.call_args[0][0]
assert call_args["type"] == "config/entity_registry/remove"
assert call_args["entity_id"] == entity_id

@pytest.mark.asyncio
async def test_remove_entity_not_found(self, remove_entity_tool, mock_client):
"""Removing a non-existent entity should raise ToolError containing 'not found'.

Note: send_websocket_message returns errors as plain strings, never as dicts.
Detection: "not found" in error_msg.lower() -- NOT error.get("code").
"""
mock_client.send_websocket_message = AsyncMock(
return_value={
"success": False,
"error": "Command failed: Entity not found",
}
)

with pytest.raises(ToolError) as exc_info:
await remove_entity_tool(entity_id="sensor.definitely_not_real_12345")

error_msg = str(exc_info.value).lower()
assert "not found" in error_msg

@pytest.mark.asyncio
async def test_remove_entity_exception(self, remove_entity_tool, mock_client):
"""WebSocket connection failure should raise ToolError."""
mock_client.send_websocket_message = AsyncMock(
side_effect=Exception("conn failed")
)

with pytest.raises(ToolError):
await remove_entity_tool(entity_id="sensor.test_entity")

@pytest.mark.asyncio
async def test_remove_entity_general_failure(self, remove_entity_tool, mock_client):
"""Generic failures should raise ToolError with SERVICE_CALL_FAILED message."""
mock_client.send_websocket_message = AsyncMock(
return_value={"success": False, "error": "Permission denied"}
)

with pytest.raises(ToolError) as exc_info:
await remove_entity_tool(entity_id="sensor.test_entity")

error_msg = str(exc_info.value).lower()
assert "permission denied" in error_msg