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
89 changes: 89 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,92 @@ 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 = result.get("error", {})
error_msg = (
error.get("message", str(error))
if isinstance(error, dict)
else str(error)
)
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},
suggestions=[
"Use ha_search_entities() to find valid entity IDs",
"The entity may have already been removed",
],
)
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},
suggestions=[
"Check HA logs for details on why the removal was rejected",
],
)
)
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 '{entity_id}': {e}")
exception_to_structured_error(
e,
context={"entity_id": entity_id},
)
64 changes: 64 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,64 @@
"""E2E tests for ha_remove_entity tool."""

import logging

import pytest

from tests.src.e2e.utilities.assertions import assert_mcp_success, 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_success(self, mcp_client):
"""Happy path: create a helper entity, remove it, verify it is gone."""
# Create a temporary input_boolean to remove
create_result = await mcp_client.call_tool(
"ha_config_set_helper",
{
"helper_type": "input_boolean",
"name": "E2E Remove Entity Test",
"icon": "mdi:test-tube",
},
)
data = assert_mcp_success(create_result, "Create test helper")
entity_id = data.get("entity_id") or f"input_boolean.{data['helper_data']['id']}"
logger.info(f"Created test entity: {entity_id}")

# Remove the entity
remove_result = await mcp_client.call_tool(
"ha_remove_entity",
{"entity_id": entity_id},
)
remove_data = assert_mcp_success(remove_result, "Remove entity")
assert remove_data.get("success") is True, f"Expected success, got: {remove_data}"
assert remove_data.get("entity_id") == entity_id

logger.info(f"Entity removed successfully: {entity_id}")

# Verify entity is gone — second removal should fail
verify_data = await safe_call_tool(
mcp_client,
"ha_remove_entity",
{"entity_id": entity_id},
)
assert not verify_data.get("success"), (
f"Entity should be gone after removal, got: {verify_data}"
)
logger.info("Entity removal verified — entity no longer exists")

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