ha-mcp-dev 7.6.0.dev679__tar.gz → 7.7.0.dev682__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 (141) hide show
  1. {ha_mcp_dev-7.6.0.dev679/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.7.0.dev682}/PKG-INFO +2 -1
  2. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/README.md +1 -0
  3. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/pyproject.toml +1 -1
  4. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/config.py +13 -0
  5. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/errors.py +4 -0
  6. ha_mcp_dev-7.7.0.dev682/src/ha_mcp/read_only.py +431 -0
  7. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/server.py +68 -0
  8. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/settings.css +6 -0
  9. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/settings.js +138 -21
  10. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/settings_ui.py +36 -1
  11. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/smoke_test.py +1 -0
  12. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/registry.py +8 -3
  13. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_integrations.py +77 -57
  14. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_search.py +23 -4
  15. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/operation_manager.py +3 -1
  16. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -1
  17. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  18. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/tests/test_env_manager.py +4 -3
  19. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/LICENSE +0 -0
  20. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/MANIFEST.in +0 -0
  21. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/setup.cfg +0 -0
  22. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/__init__.py +0 -0
  23. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/__main__.py +0 -0
  24. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/_pypi_marker +0 -0
  25. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/_version.py +0 -0
  26. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/auth/__init__.py +0 -0
  27. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/auth/consent_form.py +0 -0
  28. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/auth/provider.py +0 -0
  29. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/backup_manager.py +0 -0
  30. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/client/__init__.py +0 -0
  31. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/client/rest_client.py +0 -0
  32. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/client/supervisor_client.py +0 -0
  33. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/client/websocket_client.py +0 -0
  34. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/client/websocket_listener.py +0 -0
  35. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  36. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  37. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  38. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/__init__.py +0 -0
  39. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/approval_queue.py +0 -0
  40. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/evaluator.py +0 -0
  41. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/handlers.py +0 -0
  42. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/middleware.py +0 -0
  43. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/model.py +0 -0
  44. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/persistence.py +0 -0
  45. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/policy/value_sources.py +0 -0
  46. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/py.typed +0 -0
  47. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  48. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  49. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  50. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  51. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  52. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  53. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  54. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  55. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  56. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  57. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  58. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  59. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  60. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  61. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  62. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  63. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  64. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  65. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  66. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  67. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  68. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  69. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  70. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/__init__.py +0 -0
  71. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/auto_backup.py +0 -0
  72. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/backup.py +0 -0
  73. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  74. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/device_control.py +0 -0
  75. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/enhanced.py +0 -0
  76. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/helpers.py +0 -0
  77. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/reference_validator.py +0 -0
  78. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  79. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  80. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  81. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  82. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  83. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  84. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  85. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  86. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  87. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_addons.py +0 -0
  88. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_areas.py +0 -0
  89. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  90. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  91. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_calendar.py +0 -0
  92. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_camera.py +0 -0
  93. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_categories.py +0 -0
  94. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_code.py +0 -0
  95. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  96. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  97. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  98. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  99. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  100. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  101. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  102. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_energy.py +0 -0
  103. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_entities.py +0 -0
  104. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  105. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_groups.py +0 -0
  106. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_hacs.py +0 -0
  107. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_history.py +0 -0
  108. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_labels.py +0 -0
  109. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  110. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_registry.py +0 -0
  111. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_resources.py +0 -0
  112. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_service.py +0 -0
  113. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_services.py +0 -0
  114. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_system.py +0 -0
  115. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_todo.py +0 -0
  116. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_traces.py +0 -0
  117. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_updates.py +0 -0
  118. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_utility.py +0 -0
  119. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  120. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  121. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/tools_zones.py +0 -0
  122. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/util_helpers.py +0 -0
  123. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/tools/validation_middleware.py +0 -0
  124. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/transforms/__init__.py +0 -0
  125. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/transforms/categorized_search.py +0 -0
  126. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  127. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/__init__.py +0 -0
  128. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/config_hash.py +0 -0
  129. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/data_paths.py +0 -0
  130. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/domain_handlers.py +0 -0
  131. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  132. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  133. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/python_sandbox.py +0 -0
  134. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/skill_loader.py +0 -0
  135. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp/utils/usage_logger.py +0 -0
  136. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  137. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  138. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  139. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  140. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/tests/__init__.py +0 -0
  141. {ha_mcp_dev-7.6.0.dev679 → ha_mcp_dev-7.7.0.dev682}/tests/test_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.6.0.dev679
3
+ Version: 7.7.0.dev682
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
@@ -204,6 +204,7 @@ Spend less time configuring, more time enjoying your smart home.
204
204
  | **🔧 Manage** | Automations, scripts, helpers, dashboards, areas, zones, groups, calendars, blueprints |
205
205
  | **📊 Monitor** | History, statistics, camera snapshots, automation traces, ZHA devices |
206
206
  | **💾 System** | Backup/restore, updates, add-ons, device registry |
207
+ | **🔒 Safety** | Read Only Mode toggle, per-tool enable/disable, tool security policies (user approval), automatic edit backups |
207
208
 
208
209
  <details>
209
210
  <!-- TOOLS_TABLE_START -->
@@ -174,6 +174,7 @@ Spend less time configuring, more time enjoying your smart home.
174
174
  | **🔧 Manage** | Automations, scripts, helpers, dashboards, areas, zones, groups, calendars, blueprints |
175
175
  | **📊 Monitor** | History, statistics, camera snapshots, automation traces, ZHA devices |
176
176
  | **💾 System** | Backup/restore, updates, add-ons, device registry |
177
+ | **🔒 Safety** | Read Only Mode toggle, per-tool enable/disable, tool security policies (user approval), automatic edit backups |
177
178
 
178
179
  <details>
179
180
  <!-- TOOLS_TABLE_START -->
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.6.0.dev679"
7
+ version = "7.7.0.dev682"
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"
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
126
126
  False, alias="ENABLE_TOOL_SECURITY_POLICIES"
127
127
  )
128
128
 
129
+ # Read Only Mode — global safety toggle (discussion #1569). When on,
130
+ # write-capable tools are hidden from the MCP catalog and every write
131
+ # operation is blocked at call time with a structured READ_ONLY_MODE
132
+ # error. Mixed read/write tools whose read surface has no pure-read
133
+ # duplicate stay available with their write actions blocked (see
134
+ # read_only.py:READ_ONLY_EXEMPT_TOOLS). Off by default.
135
+ read_only_mode: bool = Field(False, alias="READ_ONLY_MODE")
136
+
129
137
  # Master beta-features toggle. UI-only — intentionally not in any
130
138
  # addon config.yaml schema. Consumed by the master gate in
131
139
  # ``_apply_feature_flag_overrides``, which force-sets the
@@ -545,6 +553,11 @@ FEATURE_FLAG_FIELDS: tuple[FeatureFlagField, ...] = (
545
553
  FeatureFlagField(
546
554
  "enable_tool_security_policies", "ENABLE_TOOL_SECURITY_POLICIES", bool
547
555
  ),
556
+ # Non-beta global safety toggle (discussion #1569). Lives here so the
557
+ # Tools-tab toggle and the Server Settings row share the same
558
+ # /api/settings/features plumbing, override-file persistence, and
559
+ # addon Supervisor routing as every other feature flag.
560
+ FeatureFlagField("read_only_mode", "READ_ONLY_MODE", bool),
548
561
  # Non-beta, default-ON master switch for write-tool skill_content
549
562
  # delivery (#1182). Grouped with the non-beta flags above the beta
550
563
  # run below; intentionally NOT in BETA_FEATURE_FIELDS (it must not be
@@ -96,6 +96,10 @@ class ErrorCode(StrEnum):
96
96
  USER_DENIED = "USER_DENIED"
97
97
  POLICY_LOAD_FAILED = "POLICY_LOAD_FAILED"
98
98
 
99
+ # Read Only Mode (discussion #1569). A write operation was blocked
100
+ # because the server-wide Read Only Mode toggle is on.
101
+ READ_ONLY_MODE = "READ_ONLY_MODE"
102
+
99
103
 
100
104
  # Default suggestions for common error codes
101
105
  DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
@@ -0,0 +1,431 @@
1
+ """Read-only mode — catalog filtering and call-time write blocking (#1569).
2
+
3
+ When ``Settings.read_only_mode`` is on (Tools-tab toggle in the web UI,
4
+ ``read_only_mode`` addon option, or ``READ_ONLY_MODE`` env var):
5
+
6
+ - ``ReadOnlyToolsTransform`` hides write-capable tools from the MCP
7
+ catalog at list time, except the exempt mixed read/write tools in
8
+ ``READ_ONLY_EXEMPT_TOOLS``.
9
+ - ``ReadOnlyMiddleware`` blocks every write operation at call time with
10
+ a structured ``READ_ONLY_MODE`` error — including the write actions
11
+ of the exempt mixed tools, which stay callable for their read
12
+ operations only.
13
+
14
+ Both consult the live settings singleton per request, so flipping the
15
+ toggle in standalone HTTP mode takes effect without a restart (addon
16
+ and stdio modes pick it up on restart, like every other feature flag).
17
+
18
+ A tool counts as write-capable when its ``readOnlyHint`` annotation is
19
+ not ``True`` — the same fail-closed default the policy handlers and the
20
+ search-proxy categorizer apply to unannotated tools.
21
+
22
+ The exempt tools are the mixed read/write tools whose read surface has
23
+ no pure-read duplicate elsewhere in the catalog — disabling them
24
+ outright would make that data unreachable in read-only mode. Each entry
25
+ carries an argument-level predicate that decides, per invocation,
26
+ whether the call is a read (allowed) or a write (blocked).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ from collections.abc import Awaitable, Callable, Sequence
34
+ from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
35
+
36
+ from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
37
+ from fastmcp.server.transforms import Transform
38
+ from fastmcp.tools import Tool
39
+
40
+ from .config import get_global_settings
41
+ from .errors import ErrorCode, create_error_response
42
+ from .policy.middleware import PROXY_META_TOOLS
43
+ from .tools.helpers import raise_tool_error
44
+
45
+ if TYPE_CHECKING:
46
+ from fastmcp.server.transforms import GetToolNext
47
+ from fastmcp.utilities.versions import VersionSpec
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ class ReadOnlyExemption(NamedTuple):
53
+ """One mixed read/write tool that stays enabled in read-only mode.
54
+
55
+ ``blocked_write`` inspects the call arguments and returns ``None``
56
+ when the invocation is a read, or a short human-readable description
57
+ of the write operation when it must be blocked. ``allowed`` is a
58
+ one-line summary of what remains available, surfaced in the error so
59
+ the LLM can self-correct.
60
+ """
61
+
62
+ blocked_write: Callable[[dict[str, Any]], str | None]
63
+ allowed: str
64
+
65
+
66
+ def _backup_write(args: dict[str, Any]) -> str | None:
67
+ scope = args.get("scope")
68
+ action = args.get("action")
69
+ if scope == "edits" and action in ("list", "view"):
70
+ return None
71
+ return f"scope={scope!r}, action={action!r}"
72
+
73
+
74
+ # Add-on parameters that, when present, mean the call mutates add-on
75
+ # configuration (so the read-only middleware must block it). Module-level
76
+ # so the schema-drift guard test (test_read_only.py) can pin an
77
+ # independent manifest against it — see item 10b / ha_manage_addon.
78
+ _ADDON_CONFIG_WRITE_PARAMS = ("options", "network", "boot", "auto_update", "watchdog")
79
+
80
+
81
+ def _addon_write(args: dict[str, Any]) -> str | None:
82
+ action = args.get("action")
83
+ if action:
84
+ return f"action={action!r}"
85
+ for param in _ADDON_CONFIG_WRITE_PARAMS:
86
+ if args.get(param) is not None:
87
+ return f"add-on configuration change ({param}=...)"
88
+ if args.get("array_patch") is not None:
89
+ return "array_patch modification"
90
+ if args.get("websocket"):
91
+ # A WebSocket session's initial message can command mutations
92
+ # (e.g. ESPHome /compile), so it is not statically classifiable
93
+ # as a read — fail closed.
94
+ return "WebSocket proxy session"
95
+ method = str(args.get("method") or "GET").strip().upper()
96
+ if method != "GET":
97
+ return f"HTTP {method} proxy request"
98
+ return None
99
+
100
+
101
+ def _energy_write(args: dict[str, Any]) -> str | None:
102
+ mode = args.get("mode")
103
+ if mode == "get":
104
+ return None
105
+ # dry_run=True previews validate/simulate without saving (every
106
+ # write mode short-circuits before energy/save_prefs). Strict
107
+ # ``is True``: the middleware sees RAW pre-validation arguments, so
108
+ # a non-bool truthy value (e.g. the string "false") that schema
109
+ # coercion could turn into False must fail closed here.
110
+ if args.get("dry_run") is True:
111
+ return None
112
+ return f"mode={mode!r}"
113
+
114
+
115
+ def _pipeline_write(args: dict[str, Any]) -> str | None:
116
+ action = args.get("action")
117
+ if action in ("list", "get"):
118
+ return None
119
+ return f"action={action!r}"
120
+
121
+
122
+ def _custom_tool_write(args: dict[str, Any]) -> str | None:
123
+ if args.get("list_saved") and not args.get("code") and not args.get("run_saved"):
124
+ return None
125
+ # Sandbox execution gets api_post / ws_send bridges that can write
126
+ # to HA directly, so running code (new or saved) is never a read.
127
+ return "sandbox code execution"
128
+
129
+
130
+ # Mixed read/write tools whose read surface has no pure-read duplicate
131
+ # (verified per tool: ha_get_addon cannot proxy-read addon-internal
132
+ # APIs; energy prefs and assist pipelines are reachable only through
133
+ # these tools; edit-backup listing exists nowhere else; the saved-tools
134
+ # cache is only listable here). Everything NOT in this table and not
135
+ # ``readOnlyHint=True`` is hidden and blocked outright.
136
+ #
137
+ # ``MANDATORY_TOOLS`` (settings_ui.py) intentionally needs no special
138
+ # case here: every mandatory tool is either ``readOnlyHint=True`` or
139
+ # present in this table (``ha_manage_backup``). The e2e test
140
+ # ``test_real_catalog_mandatory_tools_stay_available``
141
+ # (tests/src/e2e/policy/test_readonly_mode.py) guards that invariant
142
+ # against the real registered catalog at PR time, so the two sets
143
+ # cannot drift apart silently.
144
+ READ_ONLY_EXEMPT_TOOLS: dict[str, ReadOnlyExemption] = {
145
+ "ha_manage_backup": ReadOnlyExemption(
146
+ _backup_write,
147
+ "listing and viewing per-edit backups (scope='edits', action='list' or 'view')",
148
+ ),
149
+ "ha_manage_addon": ReadOnlyExemption(
150
+ _addon_write,
151
+ "HTTP GET proxy reads of add-on APIs (slug + path, method='GET')",
152
+ ),
153
+ "ha_manage_energy_prefs": ReadOnlyExemption(
154
+ _energy_write,
155
+ "reading the energy configuration (mode='get') and dry-run "
156
+ "previews (dry_run=true)",
157
+ ),
158
+ "ha_manage_pipeline": ReadOnlyExemption(
159
+ _pipeline_write,
160
+ "listing and inspecting pipelines (action='list' or 'get')",
161
+ ),
162
+ "ha_manage_custom_tool": ReadOnlyExemption(
163
+ _custom_tool_write,
164
+ "listing saved tools (list_saved=True)",
165
+ ),
166
+ }
167
+
168
+
169
+ def is_read_safe(tool: Tool) -> bool:
170
+ """Return True when the tool's annotations declare it read-only."""
171
+ annotations = getattr(tool, "annotations", None)
172
+ return bool(annotations and getattr(annotations, "readOnlyHint", None) is True)
173
+
174
+
175
+ def read_only_visible(tool: Tool) -> bool:
176
+ """Return True when the tool stays in the catalog in read-only mode."""
177
+ return is_read_safe(tool) or tool.name in READ_ONLY_EXEMPT_TOOLS
178
+
179
+
180
+ def _raise_read_only_error(
181
+ name: str, *, blocked_operation: str | None = None, allowed: str | None = None
182
+ ) -> NoReturn:
183
+ context: dict[str, Any] = {"tool_name": name, "read_only_mode": True}
184
+ if blocked_operation is not None:
185
+ context["blocked_operation"] = blocked_operation
186
+ if blocked_operation is not None and allowed is not None:
187
+ message = (
188
+ f"Read Only Mode is enabled on this Home Assistant MCP server. "
189
+ f"This call to '{name}' is a write operation "
190
+ f"({blocked_operation}) and was blocked — no changes were made. "
191
+ f"While Read Only Mode is on, '{name}' only supports: {allowed}."
192
+ )
193
+ else:
194
+ message = (
195
+ f"Read Only Mode is enabled on this Home Assistant MCP server. "
196
+ f"'{name}' is a write-capable tool, so the call was blocked — "
197
+ f"no changes were made."
198
+ )
199
+ raise_tool_error(
200
+ create_error_response(
201
+ ErrorCode.READ_ONLY_MODE,
202
+ message,
203
+ suggestions=[
204
+ "Continue with read-only tools — searching, getting, and "
205
+ + "listing data all remain available.",
206
+ "If the user wants to allow changes, they must turn off "
207
+ + "Read Only Mode in the ha-mcp settings UI (Tools tab) or "
208
+ + "the add-on configuration.",
209
+ ],
210
+ context=context,
211
+ )
212
+ )
213
+
214
+
215
+ class ReadOnlyToolsTransform(Transform):
216
+ """Hide write-capable tools from the catalog while read-only mode is on.
217
+
218
+ Installed before the search transforms so the BM25 index never
219
+ indexes hidden write tools. Consults the live flag per request —
220
+ no-op (and no per-call cost beyond the flag check) while it is off.
221
+ """
222
+
223
+ async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
224
+ if not get_global_settings().read_only_mode:
225
+ return tools
226
+ return [t for t in tools if read_only_visible(t)]
227
+
228
+ async def get_tool(
229
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
230
+ ) -> Tool | None:
231
+ tool = await call_next(name, version=version)
232
+ if tool is None or not get_global_settings().read_only_mode:
233
+ return tool
234
+ return tool if read_only_visible(tool) else None
235
+
236
+
237
+ class ReadOnlyMiddleware(Middleware):
238
+ """Block write operations at call time while read-only mode is on.
239
+
240
+ The catalog filter already hides plain write tools, but the
241
+ middleware is the actual enforcement: it covers calls routed through
242
+ the search proxies, the write actions of the exempt mixed tools, and
243
+ direct calls to hidden tools. Annotation lookups go through an
244
+ unfiltered catalog provider (injected by server.py) and are cached
245
+ (rebuilt on a cache miss — see _classify).
246
+ """
247
+
248
+ def __init__(self, *, list_tools: Callable[[], Awaitable[Sequence[Tool]]]) -> None:
249
+ self._list_tools = list_tools
250
+ self._read_safe_cache: dict[str, bool] | None = None
251
+
252
+ async def _classify(self, name: str) -> str:
253
+ """Classify ``name`` as 'read', 'write', or 'unknown'.
254
+
255
+ Backed by the unfiltered catalog; the cache rebuilds on a miss so
256
+ late-registered tools classify correctly. 'unknown' means the
257
+ tool is not registered at all — the call passes through so the
258
+ caller gets the normal unknown-tool error (nothing executable,
259
+ no write risk). An EMPTY catalog is abnormal (broken lookup) and
260
+ classifies everything 'write' — fail closed rather than letting
261
+ calls through unclassified. If the catalog lookup itself RAISES
262
+ we cannot classify anything, so we block the call with the
263
+ structured READ_ONLY_MODE error rather than let the exception
264
+ propagate opaquely (or, worse, let a future try/except return
265
+ 'unknown' and silently fail open).
266
+ """
267
+ if self._read_safe_cache is None or name not in self._read_safe_cache:
268
+ try:
269
+ tools = await self._list_tools()
270
+ except Exception:
271
+ logger.exception(
272
+ "read-only mode: tool catalog lookup failed while "
273
+ "classifying %s — blocking the call conservatively",
274
+ name,
275
+ )
276
+ raise_tool_error(
277
+ create_error_response(
278
+ ErrorCode.READ_ONLY_MODE,
279
+ f"Read Only Mode is enabled on this Home Assistant MCP "
280
+ f"server, and the tool catalog lookup needed to classify "
281
+ f"'{name}' as read or write failed. The call to '{name}' "
282
+ f"was blocked conservatively — no changes were made.",
283
+ suggestions=[
284
+ "Retry the call — the catalog lookup may succeed "
285
+ + "on the next attempt.",
286
+ "If this persists, the MCP server may be "
287
+ + "misconfigured; check the server logs.",
288
+ ],
289
+ context={"tool_name": name, "read_only_mode": True},
290
+ )
291
+ )
292
+ self._read_safe_cache = {t.name: is_read_safe(t) for t in tools}
293
+ if name in self._read_safe_cache:
294
+ return "read" if self._read_safe_cache[name] else "write"
295
+ if not self._read_safe_cache:
296
+ return "write"
297
+ return "unknown"
298
+
299
+ @staticmethod
300
+ def _coerce_arguments(arguments: Any) -> dict[str, Any] | None:
301
+ """Normalise a proxy envelope's ``arguments`` to a dict or None.
302
+
303
+ The categorized proxies tolerate ``arguments`` arriving as a JSON
304
+ string (small models sometimes serialize it) and json.loads it
305
+ AFTER this middleware runs (categorized_search.py ~313-352). So:
306
+ a dict is used as-is; an absent value (``None``) becomes ``{}``
307
+ (a legitimate no-argument call, not malformed); a string is
308
+ parsed and only a JSON *object* yields a dict. Anything else — an
309
+ unparseable string, a non-dict JSON value (list/scalar/null), or
310
+ a non-str/non-dict type — returns None, meaning "malformed
311
+ envelope".
312
+ """
313
+ if isinstance(arguments, dict):
314
+ return arguments
315
+ if arguments is None:
316
+ return {}
317
+ if isinstance(arguments, str):
318
+ try:
319
+ parsed = json.loads(arguments)
320
+ except (json.JSONDecodeError, ValueError):
321
+ return None
322
+ return parsed if isinstance(parsed, dict) else None
323
+ return None
324
+
325
+ @classmethod
326
+ def _unwrap_proxy_call(
327
+ cls,
328
+ args: dict[str, Any],
329
+ ) -> tuple[str, dict[str, Any]] | None:
330
+ """Extract the inner (tool, arguments) from a call-proxy envelope.
331
+
332
+ The categorized call proxies validate the inner name against
333
+ their category caches BEFORE dispatching — and in read-only mode
334
+ those caches no longer contain the hidden write tools, so a
335
+ proxied write would surface as a generic "tool not found" error
336
+ instead of the explanatory READ_ONLY_MODE one. Unwrapping here
337
+ lets the middleware decide on the inner call first. Mirrors the
338
+ proxy's own double-wrap unwrapping.
339
+
340
+ ``arguments`` is coerced at every level (see _coerce_arguments)
341
+ because the proxy accepts it as a JSON string. Returns None when
342
+ there is no usable envelope — including when ``arguments`` is a
343
+ MALFORMED string (json.loads fails, or the JSON is not an object).
344
+ Passing through on a malformed envelope is safe: the proxy
345
+ rejects such ``arguments`` with its own VALIDATION error BEFORE
346
+ the category check and before any dispatch
347
+ (categorized_search.py ~313-352), so no write can occur.
348
+ """
349
+ name = args.get("name")
350
+ arguments = cls._coerce_arguments(args.get("arguments"))
351
+ while (
352
+ isinstance(name, str)
353
+ and name in PROXY_META_TOOLS
354
+ and isinstance(arguments, dict)
355
+ and isinstance(arguments.get("name"), str)
356
+ ):
357
+ name = arguments.get("name")
358
+ arguments = cls._coerce_arguments(arguments.get("arguments"))
359
+ if not isinstance(name, str):
360
+ return None
361
+ if arguments is None:
362
+ # Malformed inner ``arguments`` (a string that fails
363
+ # json.loads, or a non-object JSON value / non-dict type).
364
+ # Pass through: the proxy raises its own VALIDATION error
365
+ # before any dispatch, so nothing can be written. (An absent
366
+ # ``arguments`` key is NOT malformed — it coerces to ``{}``.)
367
+ return None
368
+ return name, arguments
369
+
370
+ async def on_call_tool(
371
+ self, context: MiddlewareContext, call_next: CallNext
372
+ ) -> Any:
373
+ if not get_global_settings().read_only_mode:
374
+ return await call_next(context)
375
+
376
+ name = context.message.name
377
+ args = context.message.arguments or {}
378
+
379
+ # Call proxies: decide on the INNER call (see _unwrap_proxy_call).
380
+ # ha_search_tools and envelope-less proxy calls pass through —
381
+ # searching is a read, and the proxy raises its own validation
382
+ # error for a missing inner name. When the inner call is allowed,
383
+ # the proxy dispatch re-enters this middleware with the real tool
384
+ # name anyway (harmless re-check, same verdict).
385
+ if name in PROXY_META_TOOLS:
386
+ unwrapped = self._unwrap_proxy_call(args)
387
+ if unwrapped is None:
388
+ return await call_next(context)
389
+ inner_name, inner_args = unwrapped
390
+ exemption = READ_ONLY_EXEMPT_TOOLS.get(inner_name)
391
+ if exemption is not None:
392
+ blocked = exemption.blocked_write(inner_args)
393
+ if blocked is None:
394
+ return await call_next(context)
395
+ logger.info(
396
+ "read-only mode blocked proxied write operation of %s (%s)",
397
+ inner_name,
398
+ blocked,
399
+ )
400
+ _raise_read_only_error(
401
+ inner_name, blocked_operation=blocked, allowed=exemption.allowed
402
+ )
403
+ if await self._classify(inner_name) != "write":
404
+ # 'read' is allowed; 'unknown' falls through to the
405
+ # proxy's own not-found error.
406
+ return await call_next(context)
407
+ logger.info(
408
+ "read-only mode blocked proxied call to write tool %s", inner_name
409
+ )
410
+ _raise_read_only_error(inner_name)
411
+
412
+ exemption = READ_ONLY_EXEMPT_TOOLS.get(name)
413
+ if exemption is not None:
414
+ blocked = exemption.blocked_write(args)
415
+ if blocked is None:
416
+ return await call_next(context)
417
+ logger.info(
418
+ "read-only mode blocked write operation of %s (%s)", name, blocked
419
+ )
420
+ _raise_read_only_error(
421
+ name, blocked_operation=blocked, allowed=exemption.allowed
422
+ )
423
+
424
+ if await self._classify(name) != "write":
425
+ # 'read' is allowed; 'unknown' falls through to FastMCP's
426
+ # normal unknown-tool error.
427
+ return await call_next(context)
428
+
429
+ logger.info("read-only mode blocked call to write tool %s", name)
430
+ _raise_read_only_error(name)
431
+ return None # py/mixed-returns: unreachable, _raise_read_only_error raises
@@ -118,6 +118,26 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
118
118
  # Build server instructions from bundled skills (if enabled)
119
119
  instructions = self._build_skills_instructions()
120
120
 
121
+ # Surface Read Only Mode in the startup instructions so clients
122
+ # that show server instructions warn the model up front. Startup
123
+ # state only — live flips are covered by the structured
124
+ # READ_ONLY_MODE call errors and the ha_get_overview field.
125
+ if self.settings.read_only_mode:
126
+ read_only_note = (
127
+ "## Read Only Mode\n"
128
+ "This server is running in Read Only Mode: write-capable "
129
+ "tools are disabled and every write or destructive "
130
+ "operation is blocked with a READ_ONLY_MODE error. You can "
131
+ "search, read, and analyze freely. To allow changes, the "
132
+ "user must turn off Read Only Mode in the ha-mcp settings "
133
+ "UI (Tools tab) or the add-on configuration."
134
+ )
135
+ instructions = (
136
+ f"{instructions}\n\n{read_only_note}"
137
+ if instructions
138
+ else read_only_note
139
+ )
140
+
121
141
  # Create FastMCP server with Home Assistant icons for client UI display
122
142
  self.mcp = FastMCP(
123
143
  name=server_name,
@@ -187,6 +207,12 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
187
207
  # search indexing too).
188
208
  self._apply_settings_visibility()
189
209
 
210
+ # Read Only Mode catalog filter (discussion #1569) — always
211
+ # installed, consults the live flag per request. Must come
212
+ # before the search transforms so the BM25 index never indexes
213
+ # hidden write tools while the mode is on.
214
+ self._apply_read_only_catalog_filter()
215
+
190
216
  # Replace heavy tool descriptions with lite variants when
191
217
  # ENABLE_LITE_DOCSTRINGS=true. Must come BEFORE keyword
192
218
  # enrichment so BM25 keywords append to the lite text (instead
@@ -210,6 +236,12 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
210
236
 
211
237
  self.mcp.add_middleware(ValidationErrorMiddleware())
212
238
 
239
+ # Read Only Mode write blocker (discussion #1569) — always
240
+ # installed, consults the live flag per call. Before
241
+ # PolicyMiddleware so a write blocked by Read Only Mode never
242
+ # queues a pointless approval request.
243
+ self._apply_read_only_middleware()
244
+
213
245
  # Wire tool security policies middleware (#966) — opt-in via
214
246
  # ENABLE_TOOL_SECURITY_POLICIES. Must come last so the middleware
215
247
  # wraps the final tool surface (including the search proxies).
@@ -939,6 +971,42 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
939
971
  data_dir,
940
972
  )
941
973
 
974
+ def _apply_read_only_catalog_filter(self) -> None:
975
+ """Install the Read Only Mode catalog filter (discussion #1569).
976
+
977
+ Always installed — :class:`ReadOnlyToolsTransform` consults the
978
+ live ``read_only_mode`` flag per list request, so it is a no-op
979
+ while the mode is off and standalone-mode toggles take effect
980
+ without a restart. Hides write-capable tools (``readOnlyHint``
981
+ not True) except the exempt mixed read/write tools, whose write
982
+ actions ``ReadOnlyMiddleware`` blocks at call time instead.
983
+ """
984
+ from .read_only import ReadOnlyToolsTransform
985
+
986
+ self.mcp.add_transform(ReadOnlyToolsTransform())
987
+ if self.settings.read_only_mode:
988
+ logger.info(
989
+ "Read Only Mode is ON — write-capable tools are hidden "
990
+ "and write operations are blocked"
991
+ )
992
+
993
+ def _apply_read_only_middleware(self) -> None:
994
+ """Install the Read Only Mode write blocker (discussion #1569).
995
+
996
+ Always installed — consults the live flag per call, so there is
997
+ no per-call work beyond the flag check while the mode is off.
998
+ The annotation lookup uses the UNFILTERED tool list (private
999
+ FastMCP API, same justified usage as the settings UI and policy
1000
+ handlers) so hidden tools still resolve to a clear structured
1001
+ error instead of an opaque unknown-tool failure.
1002
+ """
1003
+ from .read_only import ReadOnlyMiddleware
1004
+
1005
+ async def _list_all_tools() -> Any:
1006
+ return await self.mcp.local_provider._list_tools()
1007
+
1008
+ self.mcp.add_middleware(ReadOnlyMiddleware(list_tools=_list_all_tools))
1009
+
942
1010
  # Shared action-phrased keyword block for retrieval. Some MCP clients
943
1011
  # (Claude Code, others) rank candidate tools by token-overlap between
944
1012
  # the user's natural-language query and each tool's `description`
@@ -187,6 +187,12 @@
187
187
  color: var(--text); font-size: 0.85rem; }
188
188
  .feature-control input[type="number"]:disabled { opacity: 0.4; cursor: not-allowed; }
189
189
  .feature-row.locked .feature-name { color: var(--text-secondary); }
190
+ /* Read Only Mode toggle row sits in the Tools tab between the summary
191
+ and the search box — card styling to match the tab's other blocks
192
+ (the bare .feature-row look is designed for the Server Settings
193
+ list, where rows separate with border-top). */
194
+ #readOnlyModeRow { background: var(--surface); border: 1px solid var(--border);
195
+ border-radius: 10px; padding: 10px 16px; margin-bottom: 16px; }
190
196
  /* Beta master toggle + nested sub-rows. The master row
191
197
  ``.beta-master-row`` is visually distinguished as a section
192
198
  header. The 5 sub-rows ``.beta-sub`` are indented with a vertical