ha-mcp-dev 7.4.1.dev454__tar.gz → 7.4.1.dev455__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 (108) hide show
  1. {ha_mcp_dev-7.4.1.dev454/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev455}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/device_control.py +64 -39
  4. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/helpers.py +44 -0
  5. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/smart_search.py +43 -40
  6. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_hacs.py +31 -28
  7. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_history.py +26 -23
  8. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_traces.py +37 -36
  9. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  10. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/LICENSE +0 -0
  11. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/README.md +0 -0
  13. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/_version.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/auth/__init__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/auth/consent_form.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/auth/provider.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/client/__init__.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/client/rest_client.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/client/websocket_client.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/client/websocket_listener.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/config.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/errors.py +0 -0
  27. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/py.typed +0 -0
  28. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  36. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  39. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  45. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  46. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  47. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  48. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/server.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/settings_ui.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/smoke_test.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/__init__.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/backup.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/enhanced.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/reference_validator.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_addons.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_calendar.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_camera.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_categories.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_code.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_energy.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_entities.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_groups.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_integrations.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_labels.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_registry.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_resources.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_search.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_service.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_services.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_system.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_todo.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_updates.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_utility.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/tools_zones.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/tools/util_helpers.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/transforms/__init__.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/transforms/categorized_search.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/config_hash.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/data_paths.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/domain_handlers.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/operation_manager.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/python_sandbox.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp/utils/usage_logger.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  106. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/tests/__init__.py +0 -0
  107. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/tests/test_constants.py +0 -0
  108. {ha_mcp_dev-7.4.1.dev454 → ha_mcp_dev-7.4.1.dev455}/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.4.1.dev454
3
+ Version: 7.4.1.dev455
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.4.1.dev454"
7
+ version = "7.4.1.dev455"
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"
@@ -8,6 +8,7 @@ and async operation verification through WebSocket monitoring.
8
8
  import asyncio
9
9
  import json
10
10
  import logging
11
+ import time
11
12
  from typing import Any, ClassVar
12
13
 
13
14
  from fastmcp import Context
@@ -19,7 +20,12 @@ from ..config import get_global_settings
19
20
  from ..errors import ErrorCode, create_error_response
20
21
  from ..utils.domain_handlers import get_domain_handler
21
22
  from ..utils.operation_manager import get_operation_from_memory, store_pending_operation
22
- from .helpers import exception_to_structured_error, raise_tool_error
23
+ from .helpers import (
24
+ exception_to_structured_error,
25
+ raise_tool_error,
26
+ safe_info,
27
+ safe_progress,
28
+ )
23
29
 
24
30
  logger = logging.getLogger(__name__)
25
31
 
@@ -394,18 +400,13 @@ class DeviceControlTools:
394
400
  async def get_device_operation_status(
395
401
  self, operation_id: str, timeout_seconds: int = 10
396
402
  ) -> dict[str, Any]:
397
- """
398
- Check status of device operation with async verification.
399
-
400
- This tool checks the status of operations initiated by control_device_smart.
401
- Results come from real-time WebSocket monitoring of Home Assistant state changes.
402
-
403
- Args:
404
- operation_id: Operation ID returned by control_device_smart
405
- timeout_seconds: Maximum time to wait for completion
403
+ """Check status of a device operation, waiting up to ``timeout_seconds`` for completion.
406
404
 
407
- Returns:
408
- Operation status with completion details or timeout info
405
+ Polls the in-memory operation registry (mutated by the WebSocket
406
+ listener as state changes arrive) every 0.2s while the operation is
407
+ pending, up to ``timeout_seconds``. Returns the final structured status
408
+ — completed/failed/timeout/pending — produced by
409
+ ``control_device_smart``.
409
410
  """
410
411
  operation = get_operation_from_memory(operation_id)
411
412
 
@@ -421,6 +422,30 @@ class DeviceControlTools:
421
422
  context={"operation_id": operation_id},
422
423
  ))
423
424
 
425
+ # Wait up to timeout_seconds for the operation to leave the pending state.
426
+ # The WebSocket listener mutates operation.status as state changes arrive,
427
+ # so polling memory is sufficient — no need to subscribe again. Uses
428
+ # time.monotonic() so the deadline can be cleanly patched in tests.
429
+ if operation.status.value == "pending" and timeout_seconds > 0:
430
+ deadline = time.monotonic() + timeout_seconds
431
+ while operation.status.value == "pending":
432
+ if time.monotonic() >= deadline:
433
+ break
434
+ await asyncio.sleep(0.2)
435
+ refreshed = get_operation_from_memory(operation_id)
436
+ if refreshed is None:
437
+ raise_tool_error(create_error_response(
438
+ ErrorCode.RESOURCE_NOT_FOUND,
439
+ "Operation cleaned up during status poll",
440
+ suggestions=[
441
+ "Operation may have completed and been purged before "
442
+ "verification finished",
443
+ "Use control_device_smart to start new operation",
444
+ ],
445
+ context={"operation_id": operation_id},
446
+ ))
447
+ operation = refreshed
448
+
424
449
  # Check operation status
425
450
  if operation.status.value == "completed":
426
451
  return {
@@ -546,7 +571,6 @@ class DeviceControlTools:
546
571
  Args:
547
572
  operations: List of device control operations
548
573
  parallel: Whether to execute operations in parallel
549
- ctx: Optional FastMCP Context for progress reporting
550
574
 
551
575
  Returns:
552
576
  Bulk operation results
@@ -568,17 +592,18 @@ class DeviceControlTools:
568
592
  operations, skipped_operations
569
593
  )
570
594
 
571
- if ctx is not None:
572
- await ctx.info(
573
- f"bulk_device_control: {len(valid_operations)} valid op(s), "
574
- f"{len(skipped_operations)} skipped, "
575
- f"mode={'parallel' if parallel else 'sequential'}"
576
- )
577
- await ctx.report_progress(
578
- progress=0,
579
- total=len(valid_operations),
580
- message="dispatching operations",
581
- )
595
+ await safe_info(
596
+ ctx,
597
+ f"bulk_device_control: {len(valid_operations)} valid op(s), "
598
+ f"{len(skipped_operations)} skipped, "
599
+ f"mode={'parallel' if parallel else 'sequential'}",
600
+ )
601
+ await safe_progress(
602
+ ctx,
603
+ progress=0,
604
+ total=len(valid_operations),
605
+ message="dispatching operations",
606
+ )
582
607
 
583
608
  # Execute only valid operations
584
609
  if parallel:
@@ -588,15 +613,15 @@ class DeviceControlTools:
588
613
  valid_operations, results, operation_ids, ctx=ctx
589
614
  )
590
615
 
591
- if ctx is not None:
592
- await ctx.report_progress(
593
- progress=len(valid_operations),
594
- total=len(valid_operations),
595
- message=(
596
- f"dispatched {len(operation_ids)} op(s); "
597
- "use get_bulk_operation_status to verify completion"
598
- ),
599
- )
616
+ await safe_progress(
617
+ ctx,
618
+ progress=len(valid_operations),
619
+ total=len(valid_operations),
620
+ message=(
621
+ f"dispatched {len(operation_ids)} op(s); "
622
+ "use get_bulk_operation_status to verify completion"
623
+ ),
624
+ )
600
625
 
601
626
  return self._build_bulk_response(
602
627
  operations, results, operation_ids, skipped_operations, parallel
@@ -682,12 +707,12 @@ class DeviceControlTools:
682
707
  ErrorCode.SERVICE_CALL_FAILED,
683
708
  f"Exception during execution: {e!s}",
684
709
  ))
685
- if ctx is not None:
686
- await ctx.report_progress(
687
- progress=i + 1,
688
- total=total,
689
- message=f"{entity_id} {action} dispatched",
690
- )
710
+ await safe_progress(
711
+ ctx,
712
+ progress=i + 1,
713
+ total=total,
714
+ message=f"{entity_id} {action} dispatched",
715
+ )
691
716
 
692
717
  def _build_bulk_response(
693
718
  self,
@@ -12,6 +12,7 @@ import sys
12
12
  import time
13
13
  from typing import Any, Literal, NoReturn, overload
14
14
 
15
+ from fastmcp import Context
15
16
  from fastmcp.exceptions import ToolError
16
17
 
17
18
  from ..client.rest_client import (
@@ -371,6 +372,49 @@ def log_tool_usage(func: Any) -> Any:
371
372
  return wrapper
372
373
 
373
374
 
375
+ async def safe_progress(
376
+ ctx: Context | None,
377
+ *,
378
+ progress: float,
379
+ total: float | None = None,
380
+ message: str | None = None,
381
+ ) -> None:
382
+ """Report progress via ``ctx.report_progress`` with best-effort error handling.
383
+
384
+ A transport hiccup on a progress notification must never convert a
385
+ successful tool result into a ``ToolError``. Transport errors are logged
386
+ at debug; ``TypeError``/``AttributeError`` are escalated to ``warning``
387
+ because they signal a signature/interface mismatch (call-site bug or
388
+ Context object missing the expected method), not a flaky client.
389
+ ``ctx is None`` short-circuits without I/O.
390
+ """
391
+ if ctx is None:
392
+ return
393
+ try:
394
+ await ctx.report_progress(progress=progress, total=total, message=message)
395
+ except (TypeError, AttributeError) as e:
396
+ logger.warning(
397
+ "ctx.report_progress signature error (%s): %s", type(e).__name__, e
398
+ )
399
+ except Exception as e:
400
+ logger.debug("ctx.report_progress failed (%s): %s", type(e).__name__, e)
401
+
402
+
403
+ async def safe_info(ctx: Context | None, message: str) -> None:
404
+ """Emit an info message via ``ctx.info`` with best-effort error handling.
405
+
406
+ Shares the rationale and exception-handling contract of ``safe_progress``.
407
+ """
408
+ if ctx is None:
409
+ return
410
+ try:
411
+ await ctx.info(message)
412
+ except (TypeError, AttributeError) as e:
413
+ logger.warning("ctx.info signature error (%s): %s", type(e).__name__, e)
414
+ except Exception as e:
415
+ logger.debug("ctx.info failed (%s): %s", type(e).__name__, e)
416
+
417
+
374
418
  def register_tool_methods(mcp: Any, instance: Any) -> None:
375
419
  """Register all @tool-decorated methods from a class instance with the MCP server.
376
420
 
@@ -10,6 +10,7 @@ import time
10
10
  from typing import Any
11
11
 
12
12
  from fastmcp import Context
13
+ from fastmcp.exceptions import ToolError
13
14
 
14
15
  from ..client.rest_client import HomeAssistantClient
15
16
  from ..config import get_global_settings
@@ -20,7 +21,7 @@ from ..utils.fuzzy_search import (
20
21
  create_fuzzy_searcher,
21
22
  tokenize,
22
23
  )
23
- from .helpers import exception_to_structured_error
24
+ from .helpers import exception_to_structured_error, safe_info, safe_progress
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
@@ -838,25 +839,25 @@ class SmartSearchTools:
838
839
  query_lower = query.lower().strip()
839
840
 
840
841
  total_phases = len(search_types) + 1 # +1 for initial state fetch
841
- if ctx is not None:
842
- await ctx.info(
843
- f"deep_search starting: query={query!r} types={search_types}"
844
- )
845
- await ctx.report_progress(
846
- progress=0,
847
- total=total_phases,
848
- message="fetching entity states",
849
- )
842
+ await safe_info(
843
+ ctx, f"deep_search starting: query={query!r} types={search_types}"
844
+ )
845
+ await safe_progress(
846
+ ctx,
847
+ progress=0,
848
+ total=total_phases,
849
+ message="fetching entity states",
850
+ )
850
851
 
851
852
  # Fetch all entities once at the beginning to avoid repeated calls
852
853
  all_entities = await self.client.get_states()
853
854
  phase_done = 1
854
- if ctx is not None:
855
- await ctx.report_progress(
856
- progress=phase_done,
857
- total=total_phases,
858
- message=f"fetched {len(all_entities)} entity states",
859
- )
855
+ await safe_progress(
856
+ ctx,
857
+ progress=phase_done,
858
+ total=total_phases,
859
+ message=f"fetched {len(all_entities)} entity states",
860
+ )
860
861
 
861
862
  # Pre-resolve unique_ids from cached entity states to avoid redundant API calls
862
863
  automation_unique_id_map = {}
@@ -1031,12 +1032,12 @@ class SmartSearchTools:
1031
1032
  )
1032
1033
 
1033
1034
  phase_done += 1
1034
- if ctx is not None:
1035
- await ctx.report_progress(
1036
- progress=phase_done,
1037
- total=total_phases,
1038
- message=f"automations searched ({len(results['automations'])} matches)",
1039
- )
1035
+ await safe_progress(
1036
+ ctx,
1037
+ progress=phase_done,
1038
+ total=total_phases,
1039
+ message=f"automations searched ({len(results['automations'])} matches)",
1040
+ )
1040
1041
 
1041
1042
  # ================================================================
1042
1043
  # SCRIPT SEARCH (same 3-tier strategy: REST bulk -> WS bulk -> individual)
@@ -1195,12 +1196,12 @@ class SmartSearchTools:
1195
1196
  )
1196
1197
 
1197
1198
  phase_done += 1
1198
- if ctx is not None:
1199
- await ctx.report_progress(
1200
- progress=phase_done,
1201
- total=total_phases,
1202
- message=f"scripts searched ({len(results['scripts'])} matches)",
1203
- )
1199
+ await safe_progress(
1200
+ ctx,
1201
+ progress=phase_done,
1202
+ total=total_phases,
1203
+ message=f"scripts searched ({len(results['scripts'])} matches)",
1204
+ )
1204
1205
 
1205
1206
  # Search helpers with parallel WebSocket calls
1206
1207
  if "helper" in search_types:
@@ -1286,12 +1287,12 @@ class SmartSearchTools:
1286
1287
  logger.debug(f"Helper list fetch failed: {result}")
1287
1288
 
1288
1289
  phase_done += 1
1289
- if ctx is not None:
1290
- await ctx.report_progress(
1291
- progress=phase_done,
1292
- total=total_phases,
1293
- message=f"helpers searched ({len(results['helpers'])} matches)",
1294
- )
1290
+ await safe_progress(
1291
+ ctx,
1292
+ progress=phase_done,
1293
+ total=total_phases,
1294
+ message=f"helpers searched ({len(results['helpers'])} matches)",
1295
+ )
1295
1296
 
1296
1297
  # ================================================================
1297
1298
  # DASHBOARD SEARCH
@@ -1386,12 +1387,12 @@ class SmartSearchTools:
1386
1387
  raise
1387
1388
 
1388
1389
  phase_done += 1
1389
- if ctx is not None:
1390
- await ctx.report_progress(
1391
- progress=phase_done,
1392
- total=total_phases,
1393
- message=f"dashboards searched ({len(results['dashboards'])} matches)",
1394
- )
1390
+ await safe_progress(
1391
+ ctx,
1392
+ progress=phase_done,
1393
+ total=total_phases,
1394
+ message=f"dashboards searched ({len(results['dashboards'])} matches)",
1395
+ )
1395
1396
 
1396
1397
  # Merge all results with their category, sort by score, and paginate
1397
1398
  tagged_results: list[tuple[str, dict[str, Any]]] = []
@@ -1438,6 +1439,8 @@ class SmartSearchTools:
1438
1439
 
1439
1440
  return response
1440
1441
 
1442
+ except ToolError:
1443
+ raise
1441
1444
  except Exception as e:
1442
1445
  logger.error(f"Error in deep_search: {e}")
1443
1446
  exception_to_structured_error(
@@ -19,6 +19,8 @@ from .helpers import (
19
19
  log_tool_usage,
20
20
  raise_tool_error,
21
21
  register_tool_methods,
22
+ safe_info,
23
+ safe_progress,
22
24
  )
23
25
  from .util_helpers import add_timezone_metadata, coerce_bool_param, coerce_int_param
24
26
 
@@ -183,16 +185,17 @@ class HacsTools:
183
185
  min_value=0,
184
186
  )
185
187
 
186
- if ctx is not None:
187
- await ctx.info(
188
- f"ha_hacs_search starting: query={query!r} "
189
- f"category={category} installed_only={installed_only_bool}"
190
- )
191
- await ctx.report_progress(
192
- progress=0,
193
- total=3,
194
- message="checking HACS availability",
195
- )
188
+ await safe_info(
189
+ ctx,
190
+ f"ha_hacs_search starting: query={query!r} "
191
+ f"category={category} installed_only={installed_only_bool}",
192
+ )
193
+ await safe_progress(
194
+ ctx,
195
+ progress=0,
196
+ total=3,
197
+ message="checking HACS availability",
198
+ )
196
199
 
197
200
  # Check if HACS is available
198
201
  await _assert_hacs_available()
@@ -208,12 +211,12 @@ class HacsTools:
208
211
  hacs_category = CATEGORY_MAP.get(category, category)
209
212
  kwargs_cmd["categories"] = [hacs_category]
210
213
 
211
- if ctx is not None:
212
- await ctx.report_progress(
213
- progress=1,
214
- total=3,
215
- message="fetching HACS repository list",
216
- )
214
+ await safe_progress(
215
+ ctx,
216
+ progress=1,
217
+ total=3,
218
+ message="fetching HACS repository list",
219
+ )
217
220
 
218
221
  response = await ws_client.send_command(
219
222
  "hacs/repositories/list", **kwargs_cmd
@@ -231,21 +234,21 @@ class HacsTools:
231
234
  )
232
235
 
233
236
  all_repositories = response.get("result", [])
234
- if ctx is not None:
235
- await ctx.report_progress(
236
- progress=2,
237
- total=3,
238
- message=f"filtering {len(all_repositories)} repositories",
239
- )
237
+ await safe_progress(
238
+ ctx,
239
+ progress=2,
240
+ total=3,
241
+ message=f"filtering {len(all_repositories)} repositories",
242
+ )
240
243
  matches = _filter_and_score_repos(
241
244
  all_repositories, query, installed_only_bool
242
245
  )
243
- if ctx is not None:
244
- await ctx.report_progress(
245
- progress=3,
246
- total=3,
247
- message=f"matched {len(matches)} repositories",
248
- )
246
+ await safe_progress(
247
+ ctx,
248
+ progress=3,
249
+ total=3,
250
+ message=f"matched {len(matches)} repositories",
251
+ )
249
252
 
250
253
  limited_matches = matches[offset_int : offset_int + max_results_int]
251
254
  has_more = (offset_int + len(limited_matches)) < len(matches)
@@ -26,6 +26,8 @@ from .helpers import (
26
26
  log_tool_usage,
27
27
  raise_tool_error,
28
28
  register_tool_methods,
29
+ safe_info,
30
+ safe_progress,
29
31
  )
30
32
  from .util_helpers import (
31
33
  add_timezone_metadata,
@@ -290,17 +292,18 @@ class HistoryTools:
290
292
  # Parse time parameters
291
293
  start_dt, end_dt = _parse_time_range(start_time, end_time, default_hours)
292
294
 
293
- if ctx is not None:
294
- await ctx.info(
295
- f"ha_get_history starting: source={source} "
296
- f"entities={len(entity_id_list)} "
297
- f"window={start_dt.isoformat()}..{end_dt.isoformat()}"
298
- )
299
- await ctx.report_progress(
300
- progress=0,
301
- total=3,
302
- message="connecting to Home Assistant WebSocket",
303
- )
295
+ await safe_info(
296
+ ctx,
297
+ f"ha_get_history starting: source={source} "
298
+ f"entities={len(entity_id_list)} "
299
+ f"window={start_dt.isoformat()}..{end_dt.isoformat()}",
300
+ )
301
+ await safe_progress(
302
+ ctx,
303
+ progress=0,
304
+ total=3,
305
+ message="connecting to Home Assistant WebSocket",
306
+ )
304
307
 
305
308
  # Connect to WebSocket (shared by both sources)
306
309
  ws_client, error = await get_connected_ws_client(
@@ -314,12 +317,12 @@ class HistoryTools:
314
317
  "Failed to connect to Home Assistant WebSocket",
315
318
  ))
316
319
 
317
- if ctx is not None:
318
- await ctx.report_progress(
319
- progress=1,
320
- total=3,
321
- message=f"querying recorder ({source})",
322
- )
320
+ await safe_progress(
321
+ ctx,
322
+ progress=1,
323
+ total=3,
324
+ message=f"querying recorder ({source})",
325
+ )
323
326
 
324
327
  try:
325
328
  if source == "statistics":
@@ -335,12 +338,12 @@ class HistoryTools:
335
338
  significant_changes_only, limit, offset,
336
339
  _DEFAULT_HISTORY_LIMIT, _MAX_HISTORY_LIMIT,
337
340
  )
338
- if ctx is not None:
339
- await ctx.report_progress(
340
- progress=3,
341
- total=3,
342
- message="recorder query complete",
343
- )
341
+ await safe_progress(
342
+ ctx,
343
+ progress=3,
344
+ total=3,
345
+ message="recorder query complete",
346
+ )
344
347
  return result
345
348
  finally:
346
349
  if ws_client:
@@ -22,6 +22,8 @@ from .helpers import (
22
22
  log_tool_usage,
23
23
  raise_tool_error,
24
24
  register_tool_methods,
25
+ safe_info,
26
+ safe_progress,
25
27
  )
26
28
 
27
29
  logger = logging.getLogger(__name__)
@@ -167,16 +169,17 @@ class TraceTools:
167
169
  # Extract the object_id (part after the domain) as fallback
168
170
  object_id = automation_id.split(".", 1)[1]
169
171
 
170
- if ctx is not None:
171
- await ctx.info(
172
- f"ha_get_automation_traces starting: id={automation_id} "
173
- f"run_id={run_id or '<list>'}"
174
- )
175
- await ctx.report_progress(
176
- progress=0,
177
- total=3,
178
- message="connecting to Home Assistant WebSocket",
179
- )
172
+ await safe_info(
173
+ ctx,
174
+ f"ha_get_automation_traces starting: id={automation_id} "
175
+ f"run_id={run_id or '<list>'}",
176
+ )
177
+ await safe_progress(
178
+ ctx,
179
+ progress=0,
180
+ total=3,
181
+ message="connecting to Home Assistant WebSocket",
182
+ )
180
183
 
181
184
  # Connect to WebSocket
182
185
  ws_client, error = await get_connected_ws_client(
@@ -198,12 +201,12 @@ class TraceTools:
198
201
  ws_client, automation_id, object_id
199
202
  )
200
203
 
201
- if ctx is not None:
202
- await ctx.report_progress(
203
- progress=1,
204
- total=3,
205
- message=f"fetching trace {'detail' if run_id else 'list'}",
206
- )
204
+ await safe_progress(
205
+ ctx,
206
+ progress=1,
207
+ total=3,
208
+ message=f"fetching trace {'detail' if run_id else 'list'}",
209
+ )
207
210
 
208
211
  if run_id:
209
212
  # Get specific trace details
@@ -225,10 +228,9 @@ class TraceTools:
225
228
  ))
226
229
 
227
230
  trace_data = result.get("result", {})
228
- if ctx is not None:
229
- await ctx.report_progress(
230
- progress=3, total=3, message="formatting trace"
231
- )
231
+ await safe_progress(
232
+ ctx, progress=3, total=3, message="formatting trace"
233
+ )
232
234
  return _format_detailed_trace(
233
235
  automation_id, run_id, trace_data,
234
236
  deduplicate=deduplicate, detailed=detailed,
@@ -253,29 +255,28 @@ class TraceTools:
253
255
 
254
256
  # If traces are empty, gather diagnostic information
255
257
  if not traces_data:
256
- if ctx is not None:
257
- await ctx.report_progress(
258
- progress=2,
259
- total=3,
260
- message="no traces; gathering diagnostics",
261
- )
258
+ await safe_progress(
259
+ ctx,
260
+ progress=2,
261
+ total=3,
262
+ message="no traces; gathering diagnostics",
263
+ )
262
264
  diagnostics = await _gather_diagnostics(
263
265
  ws_client, self._client, automation_id, domain
264
266
  )
265
- if ctx is not None:
266
- await ctx.report_progress(
267
- progress=3, total=3, message="diagnostics complete"
268
- )
267
+ await safe_progress(
268
+ ctx, progress=3, total=3, message="diagnostics complete"
269
+ )
269
270
  return _format_trace_list(
270
271
  automation_id, traces_data, limit, diagnostics
271
272
  )
272
273
 
273
- if ctx is not None:
274
- await ctx.report_progress(
275
- progress=3,
276
- total=3,
277
- message=f"listed {len(traces_data)} traces",
278
- )
274
+ await safe_progress(
275
+ ctx,
276
+ progress=3,
277
+ total=3,
278
+ message=f"listed {len(traces_data)} traces",
279
+ )
279
280
  return _format_trace_list(automation_id, traces_data, limit)
280
281
 
281
282
  finally:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev454
3
+ Version: 7.4.1.dev455
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