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.
Files changed (100) hide show
  1. {ha_mcp_dev-7.2.0.dev343/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev345}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/pyproject.toml +2 -2
  3. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/rest_client.py +28 -30
  4. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/websocket_client.py +45 -30
  5. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/server.py +20 -17
  6. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/smoke_test.py +53 -38
  7. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/python_sandbox.py +46 -48
  8. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/LICENSE +0 -0
  10. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/MANIFEST.in +0 -0
  11. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/README.md +0 -0
  12. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/setup.cfg +0 -0
  13. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/__init__.py +0 -0
  14. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/__main__.py +0 -0
  15. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/_pypi_marker +0 -0
  16. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/py.typed +0 -0
  24. {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
  25. {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
  26. {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
  27. {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
  28. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/__init__.py +0 -0
  45. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/backup.py +0 -0
  46. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  47. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/device_control.py +0 -0
  48. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/enhanced.py +0 -0
  49. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/helpers.py +0 -0
  50. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/registry.py +0 -0
  51. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/smart_search.py +0 -0
  52. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_addons.py +0 -0
  53. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_areas.py +0 -0
  54. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  55. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  56. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_calendar.py +0 -0
  57. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_camera.py +0 -0
  58. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_categories.py +0 -0
  59. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  60. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  61. {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
  62. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  63. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  64. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_entities.py +0 -0
  65. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  66. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_groups.py +0 -0
  67. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_hacs.py +0 -0
  68. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_history.py +0 -0
  69. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_integrations.py +0 -0
  70. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_labels.py +0 -0
  71. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  72. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_registry.py +0 -0
  73. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_resources.py +0 -0
  74. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_search.py +0 -0
  75. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_service.py +0 -0
  76. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_services.py +0 -0
  77. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_system.py +0 -0
  78. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_todo.py +0 -0
  79. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_traces.py +0 -0
  80. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_updates.py +0 -0
  81. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_utility.py +0 -0
  82. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  83. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  84. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/tools_zones.py +0 -0
  85. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/tools/util_helpers.py +0 -0
  86. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/transforms/__init__.py +0 -0
  87. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/transforms/categorized_search.py +0 -0
  88. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/__init__.py +0 -0
  89. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/domain_handlers.py +0 -0
  90. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  91. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/operation_manager.py +0 -0
  92. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp/utils/usage_logger.py +0 -0
  93. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  94. {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
  95. {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
  96. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  97. {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
  98. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/tests/__init__.py +0 -0
  99. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/tests/test_constants.py +0 -0
  100. {ha_mcp_dev-7.2.0.dev343 → ha_mcp_dev-7.2.0.dev345}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.2.0.dev343
3
+ Version: 7.2.0.dev345
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -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.dev343"
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
- try:
508
- # Poll with retries — slower hardware may need more time
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
- if message_id is not None:
328
- render_future = self._state.resolve_event_response(message_id)
329
- if render_future:
330
- if not render_future.cancelled():
331
- render_future.set_result(data)
332
- return
333
-
334
- event_type = data.get("event", {}).get("event_type")
335
- if event_type:
336
- for handler in self._state.get_event_handlers(event_type):
337
- try:
338
- await handler(data["event"])
339
- except Exception as e:
340
- logger.error(f"Error in event handler: {e}")
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 Exception:
716
- pass
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
- # Evict least-recently-used connection if over limit
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 Exception:
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
- # Collect available reference files for the listing.
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 main() -> int:
18
- """Run smoke tests and return exit code."""
19
- print("=" * 60)
20
- print("Home Assistant MCP Server - Smoke Test")
21
- print("=" * 60)
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
- # Test 1: Critical library imports
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
- # Test 2: Server module import
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
- # Can't continue if server module fails
52
- print("\n" + "=" * 60)
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
- # Test 3: Server instantiation
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 = HomeAssistantSmartMCPServer()
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
- # Can't continue if server creation fails
68
- print("\n" + "=" * 60)
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
- # Test 4: Tool discovery
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
- print(f"SMOKE TEST FAILED: {len(errors)} error(s)")
98
- for error in errors:
99
- print(f" - {error}")
113
+ _print_errors(errors)
100
114
  return 1
101
- else:
102
- print("SMOKE TEST PASSED: All checks successful!")
103
- print(f" - All {len(critical_imports)} critical libraries imported")
104
- print(" - Server instantiated successfully")
105
- print(f" - {tool_count} tools discovered")
106
- return 0
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
- # Check if node type is whitelisted
162
- if type(node) not in SAFE_NODES:
163
- return False, f"Forbidden node type: {type(node).__name__}"
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.2.0.dev343
3
+ Version: 7.2.0.dev345
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT