ha-mcp-dev 7.2.0.dev343__tar.gz → 7.2.0.dev345__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ha_mcp_dev-7.2.0.dev343/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev345}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/pyproject.toml +2 -2
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/rest_client.py +28 -30
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/websocket_client.py +45 -30
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/server.py +20 -17
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/smoke_test.py +53 -38
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/python_sandbox.py +46 -48
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/README.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/setup.cfg +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.2.0.
|
|
7
|
+
version = "7.2.0.dev345"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13,<3.14"
|
|
@@ -114,7 +114,6 @@ select = [
|
|
|
114
114
|
ignore = [
|
|
115
115
|
"E501", # line too long — formatter handles this
|
|
116
116
|
"B008", # function calls in defaults — FastMCP pattern
|
|
117
|
-
"C901", # complexity — too many to fix now
|
|
118
117
|
"SIM102", # collapsible-if — sometimes less readable
|
|
119
118
|
"SIM108", # ternary — sometimes less readable
|
|
120
119
|
"SIM105", # contextlib.suppress — style preference
|
|
@@ -134,6 +133,7 @@ ignore = [
|
|
|
134
133
|
[tool.ruff.lint.per-file-ignores]
|
|
135
134
|
"__init__.py" = ["F401"]
|
|
136
135
|
"tests/**/*" = ["E501", "B011"]
|
|
136
|
+
"src/ha_mcp/tools/**" = ["C901"]
|
|
137
137
|
|
|
138
138
|
[tool.pytest.ini_options]
|
|
139
139
|
testpaths = ["tests"]
|
|
@@ -504,37 +504,9 @@ class HomeAssistantClient:
|
|
|
504
504
|
actual_entity_id = None
|
|
505
505
|
entity_not_verified = False
|
|
506
506
|
if operation == "created":
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
for attempt in range(3):
|
|
510
|
-
await asyncio.sleep(1 * (attempt + 1))
|
|
511
|
-
|
|
512
|
-
states = await self.get_states()
|
|
513
|
-
for state in states:
|
|
514
|
-
if state.get("entity_id", "").startswith(
|
|
515
|
-
"automation."
|
|
516
|
-
):
|
|
517
|
-
attributes = state.get("attributes", {})
|
|
518
|
-
if attributes.get("id") == unique_id:
|
|
519
|
-
actual_entity_id = state.get("entity_id")
|
|
520
|
-
logger.debug(
|
|
521
|
-
f"Found actual entity_id for unique_id {unique_id}: {actual_entity_id}"
|
|
522
|
-
)
|
|
523
|
-
break
|
|
524
|
-
if actual_entity_id:
|
|
525
|
-
break
|
|
526
|
-
|
|
527
|
-
if not actual_entity_id:
|
|
528
|
-
entity_not_verified = True
|
|
529
|
-
logger.warning(
|
|
530
|
-
f"Automation with unique_id {unique_id} was not found in HA state after creation"
|
|
531
|
-
)
|
|
532
|
-
|
|
533
|
-
except Exception as e:
|
|
507
|
+
actual_entity_id = await self._poll_for_automation_entity(unique_id)
|
|
508
|
+
if not actual_entity_id:
|
|
534
509
|
entity_not_verified = True
|
|
535
|
-
logger.warning(
|
|
536
|
-
f"Failed to query actual entity_id for unique_id {unique_id}: {e}"
|
|
537
|
-
)
|
|
538
510
|
|
|
539
511
|
result: dict[str, Any] = {
|
|
540
512
|
"unique_id": unique_id,
|
|
@@ -552,6 +524,32 @@ class HomeAssistantClient:
|
|
|
552
524
|
) from e
|
|
553
525
|
raise
|
|
554
526
|
|
|
527
|
+
async def _poll_for_automation_entity(self, unique_id: str) -> str | None:
|
|
528
|
+
"""Poll HA state to find the entity_id assigned to a newly created automation."""
|
|
529
|
+
try:
|
|
530
|
+
for attempt in range(3):
|
|
531
|
+
await asyncio.sleep(1 * (attempt + 1))
|
|
532
|
+
states = await self.get_states()
|
|
533
|
+
for state in states:
|
|
534
|
+
if not state.get("entity_id", "").startswith("automation."):
|
|
535
|
+
continue
|
|
536
|
+
if state.get("attributes", {}).get("id") == unique_id:
|
|
537
|
+
entity_id = state.get("entity_id")
|
|
538
|
+
logger.debug(
|
|
539
|
+
f"Found actual entity_id for unique_id {unique_id}: {entity_id}"
|
|
540
|
+
)
|
|
541
|
+
return entity_id
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logger.warning(
|
|
544
|
+
f"Failed to query actual entity_id for unique_id {unique_id}: {e}"
|
|
545
|
+
)
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
logger.warning(
|
|
549
|
+
f"Automation with unique_id {unique_id} was not found in HA state after creation"
|
|
550
|
+
)
|
|
551
|
+
return None
|
|
552
|
+
|
|
555
553
|
async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
|
|
556
554
|
"""
|
|
557
555
|
Delete automation configuration by entity_id or unique_id.
|
|
@@ -324,20 +324,26 @@ class HomeAssistantWebSocketClient:
|
|
|
324
324
|
|
|
325
325
|
# Handle events
|
|
326
326
|
if message_type == "event":
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
327
|
+
await self._handle_event_message(data, message_id)
|
|
328
|
+
|
|
329
|
+
async def _handle_event_message(
|
|
330
|
+
self, data: dict[str, Any], message_id: int | None
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Handle an incoming event message."""
|
|
333
|
+
if message_id is not None:
|
|
334
|
+
render_future = self._state.resolve_event_response(message_id)
|
|
335
|
+
if render_future:
|
|
336
|
+
if not render_future.cancelled():
|
|
337
|
+
render_future.set_result(data)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
event_type = data.get("event", {}).get("event_type")
|
|
341
|
+
if event_type:
|
|
342
|
+
for handler in self._state.get_event_handlers(event_type):
|
|
343
|
+
try:
|
|
344
|
+
await handler(data["event"])
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.error(f"Error in event handler: {e}")
|
|
341
347
|
|
|
342
348
|
def _ensure_send_lock(self) -> None:
|
|
343
349
|
"""Ensure the send lock belongs to the current event loop."""
|
|
@@ -712,8 +718,13 @@ class WebSocketManager:
|
|
|
712
718
|
for client in self._clients.values():
|
|
713
719
|
try:
|
|
714
720
|
await client.disconnect()
|
|
715
|
-
except
|
|
716
|
-
|
|
721
|
+
except (OSError, asyncio.CancelledError):
|
|
722
|
+
# Best-effort cleanup — failure is expected when the
|
|
723
|
+
# event loop changed and connections are stale.
|
|
724
|
+
logger.debug(
|
|
725
|
+
"Ignoring error disconnecting stale WebSocket client",
|
|
726
|
+
exc_info=True,
|
|
727
|
+
)
|
|
717
728
|
self._clients.clear()
|
|
718
729
|
self._last_used.clear()
|
|
719
730
|
|
|
@@ -751,22 +762,26 @@ class WebSocketManager:
|
|
|
751
762
|
self._clients[key] = client
|
|
752
763
|
self._last_used[key] = time.monotonic()
|
|
753
764
|
|
|
754
|
-
|
|
755
|
-
if len(self._clients) > MAX_POOL_SIZE:
|
|
756
|
-
oldest_key = min(self._last_used, key=lambda k: self._last_used[k])
|
|
757
|
-
stale = self._clients.pop(oldest_key, None)
|
|
758
|
-
self._last_used.pop(oldest_key, None)
|
|
759
|
-
if stale:
|
|
760
|
-
try:
|
|
761
|
-
await stale.disconnect()
|
|
762
|
-
except Exception:
|
|
763
|
-
logger.warning(
|
|
764
|
-
"Error disconnecting evicted WebSocket client",
|
|
765
|
-
exc_info=True,
|
|
766
|
-
)
|
|
765
|
+
await self._evict_lru_if_needed()
|
|
767
766
|
|
|
768
767
|
return client
|
|
769
768
|
|
|
769
|
+
async def _evict_lru_if_needed(self) -> None:
|
|
770
|
+
"""Evict the least-recently-used connection if pool exceeds limit."""
|
|
771
|
+
if len(self._clients) <= MAX_POOL_SIZE:
|
|
772
|
+
return
|
|
773
|
+
oldest_key = min(self._last_used, key=lambda k: self._last_used[k])
|
|
774
|
+
stale = self._clients.pop(oldest_key, None)
|
|
775
|
+
self._last_used.pop(oldest_key, None)
|
|
776
|
+
if stale:
|
|
777
|
+
try:
|
|
778
|
+
await stale.disconnect()
|
|
779
|
+
except (OSError, asyncio.CancelledError):
|
|
780
|
+
logger.warning(
|
|
781
|
+
"Error disconnecting evicted WebSocket client",
|
|
782
|
+
exc_info=True,
|
|
783
|
+
)
|
|
784
|
+
|
|
770
785
|
async def disconnect(self) -> None:
|
|
771
786
|
"""Disconnect all WebSocket clients."""
|
|
772
787
|
self._ensure_lock()
|
|
@@ -777,7 +792,7 @@ class WebSocketManager:
|
|
|
777
792
|
for client in self._clients.values():
|
|
778
793
|
try:
|
|
779
794
|
await client.disconnect()
|
|
780
|
-
except
|
|
795
|
+
except (OSError, asyncio.CancelledError):
|
|
781
796
|
logger.warning(
|
|
782
797
|
"Error disconnecting WebSocket client", exc_info=True
|
|
783
798
|
)
|
|
@@ -570,23 +570,7 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
570
570
|
f"the file URI to load specific guides as needed."
|
|
571
571
|
)
|
|
572
572
|
|
|
573
|
-
|
|
574
|
-
# Filter out symlinks and verify path containment to prevent
|
|
575
|
-
# traversal via symlinked directories.
|
|
576
|
-
ref_files = []
|
|
577
|
-
resolved_root = skill_dir.resolve()
|
|
578
|
-
try:
|
|
579
|
-
for f in sorted(skill_dir.rglob("*")):
|
|
580
|
-
if not f.is_file() or f.is_symlink():
|
|
581
|
-
continue
|
|
582
|
-
# Ensure resolved path stays within the skill directory
|
|
583
|
-
if not f.resolve().is_relative_to(resolved_root):
|
|
584
|
-
continue
|
|
585
|
-
rel = f.relative_to(skill_dir)
|
|
586
|
-
ref_uri = f"skill://{skill_name}/{rel}"
|
|
587
|
-
ref_files.append({"name": str(rel), "uri": ref_uri})
|
|
588
|
-
except OSError:
|
|
589
|
-
logger.warning("Error reading skill files in %s", skill_dir)
|
|
573
|
+
ref_files = self._collect_skill_ref_files(skill_dir, skill_name)
|
|
590
574
|
|
|
591
575
|
# Use factory to capture ref_files in closure
|
|
592
576
|
def _make_skill_handler(
|
|
@@ -621,6 +605,25 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
621
605
|
len(ref_files),
|
|
622
606
|
)
|
|
623
607
|
|
|
608
|
+
@staticmethod
|
|
609
|
+
def _collect_skill_ref_files(
|
|
610
|
+
skill_dir: Path, skill_name: str
|
|
611
|
+
) -> list[dict[str, str]]:
|
|
612
|
+
"""Collect reference files for a skill, filtering symlinks and path traversal."""
|
|
613
|
+
ref_files: list[dict[str, str]] = []
|
|
614
|
+
resolved_root = skill_dir.resolve()
|
|
615
|
+
try:
|
|
616
|
+
for f in sorted(skill_dir.rglob("*")):
|
|
617
|
+
if not f.is_file() or f.is_symlink():
|
|
618
|
+
continue
|
|
619
|
+
if not f.resolve().is_relative_to(resolved_root):
|
|
620
|
+
continue
|
|
621
|
+
rel = f.relative_to(skill_dir)
|
|
622
|
+
ref_files.append({"name": str(rel), "uri": f"skill://{skill_name}/{rel}"})
|
|
623
|
+
except OSError:
|
|
624
|
+
logger.warning("Error reading skill files in %s", skill_dir)
|
|
625
|
+
return ref_files
|
|
626
|
+
|
|
624
627
|
# Helper methods required by EnhancedToolsMixin
|
|
625
628
|
|
|
626
629
|
async def smart_entity_search(
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Smoke test for ha-mcp binary - verifies all dependencies are bundled correctly."""
|
|
3
3
|
|
|
4
|
+
import asyncio
|
|
4
5
|
import os
|
|
5
6
|
import sys
|
|
7
|
+
from typing import Any
|
|
6
8
|
|
|
7
9
|
# Force UTF-8 encoding on Windows for Unicode output
|
|
8
10
|
if sys.platform == "win32":
|
|
@@ -14,15 +16,14 @@ os.environ.setdefault("HOMEASSISTANT_URL", "http://smoke-test:8123")
|
|
|
14
16
|
os.environ.setdefault("HOMEASSISTANT_TOKEN", "smoke-test-token")
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
def
|
|
18
|
-
""
|
|
19
|
-
print("
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
def _print_errors(errors: list[str]) -> None:
|
|
20
|
+
print("\n" + "=" * 60)
|
|
21
|
+
print(f"SMOKE TEST FAILED: {len(errors)} error(s)")
|
|
22
|
+
for error in errors:
|
|
23
|
+
print(f" - {error}")
|
|
22
24
|
|
|
23
|
-
errors = []
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
def _test_critical_imports(errors: list[str]) -> int:
|
|
26
27
|
print("\n[1/4] Testing critical library imports...")
|
|
27
28
|
critical_imports = [
|
|
28
29
|
("fastmcp", "FastMCP framework"),
|
|
@@ -31,7 +32,6 @@ def main() -> int:
|
|
|
31
32
|
("click", "CLI framework"),
|
|
32
33
|
("websockets", "WebSocket support"),
|
|
33
34
|
]
|
|
34
|
-
|
|
35
35
|
for module_name, description in critical_imports:
|
|
36
36
|
try:
|
|
37
37
|
__import__(module_name)
|
|
@@ -39,43 +39,37 @@ def main() -> int:
|
|
|
39
39
|
except ImportError as e:
|
|
40
40
|
errors.append(f"Failed to import {module_name}: {e}")
|
|
41
41
|
print(f" ✗ {module_name} ({description}) - FAILED: {e}")
|
|
42
|
+
return len(critical_imports)
|
|
43
|
+
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
def _test_server_import(errors: list[str]) -> type | None:
|
|
44
46
|
print("\n[2/4] Testing server module import...")
|
|
45
47
|
try:
|
|
46
48
|
from ha_mcp.server import HomeAssistantSmartMCPServer
|
|
47
49
|
print(" ✓ Server module imported successfully")
|
|
50
|
+
return HomeAssistantSmartMCPServer
|
|
48
51
|
except Exception as e:
|
|
49
52
|
errors.append(f"Failed to import server module: {e}")
|
|
50
53
|
print(f" ✗ Server module import - FAILED: {e}")
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
print(f"SMOKE TEST FAILED: {len(errors)} error(s)")
|
|
54
|
-
for error in errors:
|
|
55
|
-
print(f" - {error}")
|
|
56
|
-
return 1
|
|
54
|
+
return None
|
|
55
|
+
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
def _test_server_instantiation(errors: list[str], server_cls: type) -> Any | None:
|
|
59
58
|
print("\n[3/4] Testing server instantiation...")
|
|
60
59
|
try:
|
|
61
|
-
server =
|
|
60
|
+
server = server_cls()
|
|
62
61
|
mcp = server.mcp
|
|
63
62
|
print(f" ✓ Server created: {mcp.name}")
|
|
63
|
+
return mcp
|
|
64
64
|
except Exception as e:
|
|
65
65
|
errors.append(f"Failed to create server: {e}")
|
|
66
66
|
print(f" ✗ Server instantiation - FAILED: {e}")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
print(f"SMOKE TEST FAILED: {len(errors)} error(s)")
|
|
70
|
-
for error in errors:
|
|
71
|
-
print(f" - {error}")
|
|
72
|
-
return 1
|
|
67
|
+
return None
|
|
68
|
+
|
|
73
69
|
|
|
74
|
-
|
|
70
|
+
def _test_tool_discovery(errors: list[str], mcp: Any) -> int:
|
|
75
71
|
print("\n[4/4] Testing tool discovery...")
|
|
76
72
|
try:
|
|
77
|
-
import asyncio
|
|
78
|
-
|
|
79
73
|
tools = asyncio.run(mcp.list_tools())
|
|
80
74
|
tool_count = len(tools)
|
|
81
75
|
print(f" ✓ Discovered {tool_count} tools")
|
|
@@ -84,26 +78,47 @@ def main() -> int:
|
|
|
84
78
|
errors.append(f"Too few tools discovered: {tool_count} (expected 50+)")
|
|
85
79
|
print(" ✗ Tool count too low (expected 50+)")
|
|
86
80
|
else:
|
|
87
|
-
# List a few tool names as examples
|
|
88
81
|
tool_names = [t.name for t in tools[:5]]
|
|
89
82
|
print(f" ✓ Sample tools: {', '.join(tool_names)}...")
|
|
83
|
+
return tool_count
|
|
90
84
|
except Exception as e:
|
|
91
85
|
errors.append(f"Failed to discover tools: {e}")
|
|
92
86
|
print(f" ✗ Tool discovery - FAILED: {e}")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> int:
|
|
91
|
+
"""Run smoke tests and return exit code."""
|
|
92
|
+
print("=" * 60)
|
|
93
|
+
print("Home Assistant MCP Server - Smoke Test")
|
|
94
|
+
print("=" * 60)
|
|
95
|
+
|
|
96
|
+
errors: list[str] = []
|
|
97
|
+
|
|
98
|
+
import_count = _test_critical_imports(errors)
|
|
99
|
+
|
|
100
|
+
server_cls = _test_server_import(errors)
|
|
101
|
+
if server_cls is None:
|
|
102
|
+
_print_errors(errors)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
mcp = _test_server_instantiation(errors, server_cls)
|
|
106
|
+
if mcp is None:
|
|
107
|
+
_print_errors(errors)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
tool_count = _test_tool_discovery(errors, mcp)
|
|
93
111
|
|
|
94
|
-
# Summary
|
|
95
|
-
print("\n" + "=" * 60)
|
|
96
112
|
if errors:
|
|
97
|
-
|
|
98
|
-
for error in errors:
|
|
99
|
-
print(f" - {error}")
|
|
113
|
+
_print_errors(errors)
|
|
100
114
|
return 1
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
|
|
116
|
+
print("\n" + "=" * 60)
|
|
117
|
+
print("SMOKE TEST PASSED: All checks successful!")
|
|
118
|
+
print(f" - All {import_count} critical libraries imported")
|
|
119
|
+
print(" - Server instantiated successfully")
|
|
120
|
+
print(f" - {tool_count} tools discovered")
|
|
121
|
+
return 0
|
|
107
122
|
|
|
108
123
|
|
|
109
124
|
if __name__ == "__main__":
|
|
@@ -158,58 +158,56 @@ def validate_expression(expr: str) -> tuple[bool, str]:
|
|
|
158
158
|
|
|
159
159
|
# Validate all nodes
|
|
160
160
|
for node in ast.walk(tree):
|
|
161
|
-
|
|
162
|
-
if
|
|
163
|
-
return False,
|
|
164
|
-
|
|
165
|
-
# Block imports
|
|
166
|
-
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
167
|
-
return False, "Forbidden: imports not allowed"
|
|
168
|
-
|
|
169
|
-
# Block dunder attribute access
|
|
170
|
-
if isinstance(node, ast.Attribute):
|
|
171
|
-
if node.attr.startswith("__") and node.attr.endswith("__"):
|
|
172
|
-
return False, f"Forbidden: dunder attribute access ({node.attr})"
|
|
173
|
-
|
|
174
|
-
# Validate function calls
|
|
175
|
-
if isinstance(node, ast.Call):
|
|
176
|
-
# Direct function calls (e.g., eval(), open())
|
|
177
|
-
if isinstance(node.func, ast.Name):
|
|
178
|
-
func_name = node.func.id
|
|
179
|
-
if func_name in BLOCKED_FUNCTIONS:
|
|
180
|
-
return False, f"Forbidden function: {func_name}"
|
|
181
|
-
|
|
182
|
-
# Method calls (e.g., config.append())
|
|
183
|
-
elif isinstance(node.func, ast.Attribute):
|
|
184
|
-
method_name = node.func.attr
|
|
185
|
-
if method_name.startswith("__") and method_name.endswith("__"):
|
|
186
|
-
return False, f"Forbidden: dunder method call ({method_name})"
|
|
187
|
-
if method_name not in SAFE_METHODS:
|
|
188
|
-
return (
|
|
189
|
-
False,
|
|
190
|
-
f"Forbidden method: {method_name} (allowed: {', '.join(sorted(SAFE_METHODS))})",
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
# Reject subscript calls, chained calls, and all other non-standard targets
|
|
194
|
-
# e.g., config['fn']() or config.get('fn')() would bypass Name/Attribute checks
|
|
195
|
-
else:
|
|
196
|
-
return False, f"Forbidden call target type: {type(node.func).__name__}"
|
|
197
|
-
|
|
198
|
-
# Block function definitions (could be used for obfuscation)
|
|
199
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
200
|
-
return False, "Forbidden: function/class definitions not allowed"
|
|
201
|
-
|
|
202
|
-
# Block with statements (context managers)
|
|
203
|
-
if isinstance(node, (ast.With, ast.AsyncWith)):
|
|
204
|
-
return False, "Forbidden: with statements not allowed"
|
|
205
|
-
|
|
206
|
-
# Block try/except (could hide errors)
|
|
207
|
-
if isinstance(node, (ast.Try, ast.ExceptHandler)):
|
|
208
|
-
return False, "Forbidden: try/except not allowed"
|
|
161
|
+
error = _validate_node(node)
|
|
162
|
+
if error:
|
|
163
|
+
return False, error
|
|
209
164
|
|
|
210
165
|
return True, ""
|
|
211
166
|
|
|
212
167
|
|
|
168
|
+
def _validate_node(node: ast.AST) -> str | None:
|
|
169
|
+
"""Validate a single AST node. Returns error message or None if safe."""
|
|
170
|
+
if type(node) not in SAFE_NODES:
|
|
171
|
+
return f"Forbidden node type: {type(node).__name__}"
|
|
172
|
+
|
|
173
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
174
|
+
return "Forbidden: imports not allowed"
|
|
175
|
+
|
|
176
|
+
if isinstance(node, ast.Attribute):
|
|
177
|
+
if node.attr.startswith("__") and node.attr.endswith("__"):
|
|
178
|
+
return f"Forbidden: dunder attribute access ({node.attr})"
|
|
179
|
+
|
|
180
|
+
if isinstance(node, ast.Call):
|
|
181
|
+
return _validate_call_node(node)
|
|
182
|
+
|
|
183
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
184
|
+
return "Forbidden: function/class definitions not allowed"
|
|
185
|
+
|
|
186
|
+
if isinstance(node, (ast.With, ast.AsyncWith)):
|
|
187
|
+
return "Forbidden: with statements not allowed"
|
|
188
|
+
|
|
189
|
+
if isinstance(node, (ast.Try, ast.ExceptHandler)):
|
|
190
|
+
return "Forbidden: try/except not allowed"
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _validate_call_node(node: ast.Call) -> str | None:
|
|
196
|
+
"""Validate a function/method call node. Returns error message or None."""
|
|
197
|
+
if isinstance(node.func, ast.Name):
|
|
198
|
+
if node.func.id in BLOCKED_FUNCTIONS:
|
|
199
|
+
return f"Forbidden function: {node.func.id}"
|
|
200
|
+
elif isinstance(node.func, ast.Attribute):
|
|
201
|
+
method_name = node.func.attr
|
|
202
|
+
if method_name.startswith("__") and method_name.endswith("__"):
|
|
203
|
+
return f"Forbidden: dunder method call ({method_name})"
|
|
204
|
+
if method_name not in SAFE_METHODS:
|
|
205
|
+
return f"Forbidden method: {method_name} (allowed: {', '.join(sorted(SAFE_METHODS))})"
|
|
206
|
+
else:
|
|
207
|
+
return f"Forbidden call target type: {type(node.func).__name__}"
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
213
211
|
def safe_execute(expr: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
214
212
|
"""
|
|
215
213
|
Execute validated Python expression in restricted environment.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|