Skip to content

Commit a5ad173

Browse files
committed
feat: add ha_fire_event tool for firing events on the HA event bus (#996)
- New ha_fire_event tool in ServiceTools; accepts event_type + optional data (dict or JSON string). List data rejected with ToolError. - Adds fire to approved tool verb list in AGENTS.md. - Unit tests (7): no-data, dict, JSON-string, list-rejected, fallback-message, connection-error, forwarding. - E2E tests (5): no-data, dict-data, JSON-string, list-rejected, built-in event.
1 parent 5703112 commit a5ad173

4 files changed

Lines changed: 196 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ These feed the picker tiles in the markup section AND the wizard `<script>` bloc
508508
- `delete` — delete dashboards, config entries, or files (`ha_config_delete_dashboard`, `ha_delete_file`)
509509
- `remove` — remove registry items (`ha_remove_entity`, `ha_remove_area_or_floor`)
510510
- `call` — execute (`ha_call_service`)
511+
- `fire` — dispatch a one-shot event onto the HA event bus (`ha_fire_event`)
511512
- `manage` — multi-modal tools combining several operations behind one interface (`ha_manage_addon`)
512513

513514
**Namespace prefixes**: An optional `<namespace>_` prefix between `ha_` and the verb is allowed for grouped tool families that share a domain. The full shape becomes `ha_<namespace>_<verb>_<noun>`:

src/ha_mcp/tools/tools_service.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,62 @@ async def ha_bulk_control(
431431
)
432432
return cast(dict[str, Any], result)
433433

434+
@tool(
435+
name="ha_fire_event",
436+
tags={"Service & Device Control"},
437+
annotations={
438+
"destructiveHint": False,
439+
"idempotentHint": False,
440+
"title": "Fire Event",
441+
},
442+
)
443+
@log_tool_usage
444+
async def ha_fire_event(
445+
self,
446+
event_type: str,
447+
data: str | dict[str, Any] | None = None,
448+
) -> dict[str, Any]:
449+
"""Fire a Home Assistant event on the event bus.
450+
451+
When NOT to use: for controlling entities (lights, switches, climate) — use
452+
ha_call_service instead. For triggering automations by name, use
453+
ha_call_service("automation", "trigger").
454+
455+
Use this to fire custom event types consumed by event-triggered automations,
456+
Node-RED flows, or custom integrations that subscribe to specific event types.
457+
"""
458+
try:
459+
parsed_data: dict[str, Any] | None = None
460+
if data is not None:
461+
raw = parse_json_param(data, "data")
462+
if raw is not None and not isinstance(raw, dict):
463+
raise_tool_error(
464+
create_validation_error(
465+
"Event data must be a JSON object (dict), not a list",
466+
parameter="data",
467+
)
468+
)
469+
parsed_data = cast(dict[str, Any], raw)
470+
471+
response = await self._client.fire_event(event_type, parsed_data)
472+
473+
return {
474+
"success": True,
475+
"event_type": event_type,
476+
"message": response.get("message", f"Event {event_type} fired."),
477+
}
478+
except ToolError:
479+
raise
480+
except Exception as e:
481+
exception_to_structured_error(
482+
e,
483+
context={"event_type": event_type},
484+
suggestions=[
485+
"Check Home Assistant connection",
486+
"Verify event_type is a valid identifier",
487+
],
488+
)
489+
434490

435491
def register_service_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
436492
"""Register service call and operation monitoring tools with the MCP server."""
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
E2E tests for ha_fire_event tool - fire events onto the HA event bus.
3+
"""
4+
5+
import logging
6+
7+
import pytest
8+
from fastmcp.exceptions import ToolError
9+
10+
from ..utilities.assertions import assert_mcp_success, safe_call_tool
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_fire_event_no_data(mcp_client):
17+
"""Fire a custom event without event data."""
18+
result = await mcp_client.call_tool(
19+
"ha_fire_event",
20+
{"event_type": "test_mcp_event_no_data"},
21+
)
22+
data = assert_mcp_success(result, "fire event without data")
23+
assert data["success"] is True
24+
assert data["event_type"] == "test_mcp_event_no_data"
25+
assert "message" in data
26+
logger.info("Fired event without data: %s", data)
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_fire_event_with_dict_data(mcp_client):
31+
"""Fire a custom event with dict event data."""
32+
result = await mcp_client.call_tool(
33+
"ha_fire_event",
34+
{"event_type": "test_mcp_event_with_data", "data": {"key": "value", "count": 1}},
35+
)
36+
data = assert_mcp_success(result, "fire event with dict data")
37+
assert data["success"] is True
38+
assert data["event_type"] == "test_mcp_event_with_data"
39+
logger.info("Fired event with dict data: %s", data)
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_fire_event_with_json_string_data(mcp_client):
44+
"""Fire a custom event with JSON-string event data (auto-parsed)."""
45+
result = await mcp_client.call_tool(
46+
"ha_fire_event",
47+
{"event_type": "test_mcp_event_json_str", "data": '{"source": "e2e_test"}'},
48+
)
49+
data = assert_mcp_success(result, "fire event with JSON string data")
50+
assert data["success"] is True
51+
assert data["event_type"] == "test_mcp_event_json_str"
52+
logger.info("Fired event with JSON string data: %s", data)
53+
54+
55+
@pytest.mark.asyncio
56+
async def test_fire_event_list_data_rejected(mcp_client):
57+
"""Event data must be a dict — a JSON array is rejected with ToolError."""
58+
with pytest.raises(ToolError):
59+
await mcp_client.call_tool(
60+
"ha_fire_event",
61+
{"event_type": "test_mcp_event_bad_data", "data": "[1, 2, 3]"},
62+
)
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_fire_builtin_event_type(mcp_client):
67+
"""Fire a built-in HA event type to verify the API accepts it."""
68+
result = await mcp_client.call_tool(
69+
"ha_fire_event",
70+
{"event_type": "homeassistant_start"},
71+
)
72+
data = assert_mcp_success(result, "fire homeassistant_start event")
73+
assert data["success"] is True
74+
assert data["event_type"] == "homeassistant_start"
75+
logger.info("Fired homeassistant_start: %s", data)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Unit tests for ha_fire_event tool."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
import pytest
6+
from fastmcp.exceptions import ToolError
7+
8+
from ha_mcp.tools.tools_service import ServiceTools
9+
10+
11+
def _make_tools(fire_event_return: dict | Exception) -> ServiceTools:
12+
client = MagicMock()
13+
if isinstance(fire_event_return, Exception):
14+
client.fire_event = AsyncMock(side_effect=fire_event_return)
15+
else:
16+
client.fire_event = AsyncMock(return_value=fire_event_return)
17+
tools = ServiceTools.__new__(ServiceTools)
18+
tools._client = client
19+
tools._device_tools = MagicMock()
20+
return tools
21+
22+
23+
class TestHaFireEvent:
24+
async def test_fires_event_with_no_data(self):
25+
tools = _make_tools({"message": "Event my_event fired."})
26+
result = await tools.ha_fire_event("my_event")
27+
assert result["success"] is True
28+
assert result["event_type"] == "my_event"
29+
assert "fired" in result["message"]
30+
tools._client.fire_event.assert_called_once_with("my_event", None)
31+
32+
async def test_fires_event_with_dict_data(self):
33+
tools = _make_tools({"message": "Event custom_event fired."})
34+
result = await tools.ha_fire_event("custom_event", {"key": "value"})
35+
assert result["success"] is True
36+
tools._client.fire_event.assert_called_once_with("custom_event", {"key": "value"})
37+
38+
async def test_fires_event_with_json_string_data(self):
39+
tools = _make_tools({"message": "Event my_event fired."})
40+
result = await tools.ha_fire_event("my_event", '{"temperature": 22}')
41+
assert result["success"] is True
42+
tools._client.fire_event.assert_called_once_with("my_event", {"temperature": 22})
43+
44+
async def test_raises_tool_error_on_list_data(self):
45+
tools = _make_tools({"message": "ok"})
46+
with pytest.raises(ToolError):
47+
await tools.ha_fire_event("my_event", "[1, 2, 3]")
48+
49+
async def test_returns_fallback_message_when_response_empty(self):
50+
tools = _make_tools({})
51+
result = await tools.ha_fire_event("my_event")
52+
assert "my_event" in result["message"]
53+
54+
async def test_propagates_connection_error_as_tool_error(self):
55+
tools = _make_tools(ConnectionError("HA unreachable"))
56+
with pytest.raises(ToolError):
57+
await tools.ha_fire_event("my_event")
58+
59+
async def test_event_type_passed_to_client(self):
60+
tools = _make_tools({"message": "Event zone_entered fired."})
61+
await tools.ha_fire_event("zone_entered", {"zone": "home"})
62+
call_args = tools._client.fire_event.call_args
63+
assert call_args[0][0] == "zone_entered"
64+
assert call_args[0][1] == {"zone": "home"}

0 commit comments

Comments
 (0)