ha-mcp-dev 7.14.2.dev2107__tar.gz → 7.14.2.dev2112__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 (191) hide show
  1. {ha_mcp_dev-7.14.2.dev2107/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.14.2.dev2112}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/__main__.py +140 -19
  4. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/errors.py +8 -0
  5. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/llm_exposure.py +67 -6
  6. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/handlers.py +24 -18
  7. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/middleware.py +24 -1
  8. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/model.py +11 -0
  9. ha_mcp_dev-7.14.2.dev2112/src/ha_mcp/policy/persistence.py +125 -0
  10. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/server.py +70 -1
  11. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_handlers_backups.py +76 -42
  12. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_handlers_tools.py +59 -52
  13. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_persistence.py +13 -6
  14. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/locales/de.json +7 -2
  15. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/locales/en.json +7 -2
  16. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/locales/ru.json +7 -2
  17. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/locales/zh-Hans.json +4 -1
  18. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/settings.html +10 -3
  19. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/settings.js +146 -48
  20. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/registry.py +6 -0
  21. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_deep.py +26 -0
  22. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_dev.py +1023 -101
  23. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_search.py +40 -1
  24. ha_mcp_dev-7.14.2.dev2112/src/ha_mcp/utils/config_write_lock.py +130 -0
  25. ha_mcp_dev-7.14.2.dev2112/src/ha_mcp/visibility/enforcement.py +590 -0
  26. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/visibility/model.py +7 -0
  27. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/visibility/resolver.py +94 -18
  28. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  29. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp_dev.egg-info/SOURCES.txt +2 -0
  30. ha_mcp_dev-7.14.2.dev2107/src/ha_mcp/policy/persistence.py +0 -43
  31. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/LICENSE +0 -0
  32. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/MANIFEST.in +0 -0
  33. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/README.md +0 -0
  34. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/setup.cfg +0 -0
  35. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/__init__.py +0 -0
  36. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/_pypi_marker +0 -0
  37. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/_version.py +0 -0
  38. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/auth/__init__.py +0 -0
  39. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/auth/consent_form.py +0 -0
  40. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/auth/provider.py +0 -0
  41. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/backup_manager.py +0 -0
  42. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/browser_landing.py +0 -0
  43. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/client/__init__.py +0 -0
  44. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/client/rest_client.py +0 -0
  45. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/client/supervisor_client.py +0 -0
  46. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/client/websocket_client.py +0 -0
  47. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/client/websocket_listener.py +0 -0
  48. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/config.py +0 -0
  49. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  50. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  51. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  52. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  53. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  54. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
  55. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/__init__.py +0 -0
  56. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/approval_queue.py +0 -0
  57. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/evaluator.py +0 -0
  58. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/policy/value_sources.py +0 -0
  59. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/py.typed +0 -0
  60. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/read_only.py +0 -0
  61. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  62. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  63. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  64. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  65. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  66. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  67. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  68. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  69. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  70. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  71. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  72. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  73. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
  74. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  75. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
  76. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  77. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  78. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  79. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  80. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  81. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  82. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  83. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
  84. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  85. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  86. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/__init__.py +0 -0
  87. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  88. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  89. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  90. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  91. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  92. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  93. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_theme.py +0 -0
  94. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  95. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  96. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/settings_ui/settings.css +0 -0
  97. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/smoke_test.py +0 -0
  98. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  99. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/strict_bps.py +0 -0
  100. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/__init__.py +0 -0
  101. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/auto_backup.py +0 -0
  102. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/backup.py +0 -0
  103. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  104. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/component_api.py +0 -0
  105. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/component_config_reads.py +0 -0
  106. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/component_devices.py +0 -0
  107. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/component_registries.py +0 -0
  108. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
  109. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  110. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/device_control.py +0 -0
  111. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/enhanced.py +0 -0
  112. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/helpers.py +0 -0
  113. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/radio/__init__.py +0 -0
  114. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/radio/base.py +0 -0
  115. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/radio/matter.py +0 -0
  116. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/radio/thread.py +0 -0
  117. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  118. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/radio/zwave.py +0 -0
  119. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/reference_validator.py +0 -0
  120. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  121. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  122. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  123. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  124. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  125. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  126. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  127. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  128. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  129. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_addons.py +0 -0
  130. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_areas.py +0 -0
  131. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  132. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  133. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_calendar.py +0 -0
  134. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_camera.py +0 -0
  135. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_categories.py +0 -0
  136. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_code.py +0 -0
  137. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  138. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  139. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  140. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  141. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  142. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  143. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_energy.py +0 -0
  144. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_entities.py +0 -0
  145. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  146. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_groups.py +0 -0
  147. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_hacs.py +0 -0
  148. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_history.py +0 -0
  149. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_integrations.py +0 -0
  150. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_labels.py +0 -0
  151. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_radio.py +0 -0
  152. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_registry.py +0 -0
  153. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_resources.py +0 -0
  154. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_service.py +0 -0
  155. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_services.py +0 -0
  156. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_system.py +0 -0
  157. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_themes.py +0 -0
  158. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_todo.py +0 -0
  159. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_traces.py +0 -0
  160. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_updates.py +0 -0
  161. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_utility.py +0 -0
  162. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  163. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  164. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
  165. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/tools_zones.py +0 -0
  166. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/util_helpers.py +0 -0
  167. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/tools/validation_middleware.py +0 -0
  168. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/transforms/__init__.py +0 -0
  169. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/transforms/categorized_search.py +0 -0
  170. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  171. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/transport_security.py +0 -0
  172. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/update_check.py +0 -0
  173. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/__init__.py +0 -0
  174. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/config_hash.py +0 -0
  175. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/data_paths.py +0 -0
  176. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/domain_handlers.py +0 -0
  177. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  178. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  179. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/operation_manager.py +0 -0
  180. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/python_sandbox.py +0 -0
  181. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/skill_loader.py +0 -0
  182. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/utils/usage_logger.py +0 -0
  183. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/visibility/__init__.py +0 -0
  184. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp/visibility/persistence.py +0 -0
  185. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  186. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  187. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  188. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  189. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/tests/__init__.py +0 -0
  190. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/tests/test_constants.py +0 -0
  191. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2112}/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.14.2.dev2107
3
+ Version: 7.14.2.dev2112
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.14.2.dev2107"
7
+ version = "7.14.2.dev2112"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  # Upper bound widened to <3.15 so the package installs on Home Assistant's
@@ -31,7 +31,7 @@ import stat # noqa: E402
31
31
  import sys # noqa: E402
32
32
  import threading # noqa: E402
33
33
  from collections.abc import Coroutine # noqa: E402
34
- from typing import TYPE_CHECKING, Any # noqa: E402
34
+ from typing import TYPE_CHECKING, Any, NoReturn # noqa: E402
35
35
 
36
36
  from fastmcp.exceptions import ToolError # noqa: E402
37
37
  from pydantic import ValidationError as PydanticValidationError # noqa: E402
@@ -131,6 +131,16 @@ SHUTDOWN_TIMEOUT_SECONDS = 2.0
131
131
  _shutdown_event: asyncio.Event | None = None
132
132
  _shutdown_in_progress = False
133
133
 
134
+ # Set by the stdio entry point (main) so signal-initiated shutdown arms a
135
+ # hard-deadline watchdog. HTTP entry points leave this False.
136
+ _force_exit_on_shutdown = False
137
+
138
+ # Hard deadline for a signal-initiated stdio shutdown. Must exceed the sum
139
+ # of the graceful phases (server-stop wait + resource cleanup + task
140
+ # cancellation, SHUTDOWN_TIMEOUT_SECONDS each) so it only fires when the
141
+ # graceful path is truly stuck (issue #2027).
142
+ _SHUTDOWN_WATCHDOG_SECONDS = 10.0
143
+
134
144
  # Stdin error message for Docker without -i flag
135
145
  _STDIN_ERROR_MESSAGE = """
136
146
  ==============================================================================
@@ -589,16 +599,29 @@ async def _cleanup_resources() -> None:
589
599
 
590
600
 
591
601
  async def _cancel_tasks(*tasks: asyncio.Task) -> None:
592
- """Cancel tasks and wait for completion, swallowing CancelledError."""
593
- for task in tasks:
594
- if not task.done():
595
- task.cancel()
596
- try:
597
- await task
598
- except asyncio.CancelledError:
599
- # Expected: we just cancelled this task, swallow its
600
- # CancelledError so remaining tasks still get awaited.
601
- pass
602
+ """Cancel tasks and wait for completion, bounding the wait.
603
+
604
+ A task can ignore cancellation indefinitely — the stdio transport does
605
+ when a blocking stdin read is in flight (issue #2027) — so the wait is
606
+ bounded instead of awaiting each task unboundedly. Stragglers are
607
+ logged and abandoned; the stdio entry point force-exits anyway.
608
+ """
609
+ pending = [task for task in tasks if not task.done()]
610
+ if not pending:
611
+ return
612
+ for task in pending:
613
+ task.cancel()
614
+ done, still_pending = await asyncio.wait(pending, timeout=SHUTDOWN_TIMEOUT_SECONDS)
615
+ if still_pending:
616
+ logger.warning(
617
+ "%d task(s) ignored cancellation during shutdown", len(still_pending)
618
+ )
619
+ for task in done:
620
+ if task.cancelled():
621
+ continue
622
+ exc = task.exception()
623
+ if exc is not None:
624
+ logger.warning(f"Task raised during shutdown: {exc!r}")
602
625
 
603
626
 
604
627
  async def _run_with_shutdown(server_coro: Coroutine[Any, Any, Any]) -> None:
@@ -622,14 +645,22 @@ async def _run_with_shutdown(server_coro: Coroutine[Any, Any, Any]) -> None:
622
645
  if shutdown_task in done:
623
646
  logger.info("Shutdown signal received, stopping server...")
624
647
  server_task.cancel()
625
- try:
626
- await asyncio.wait_for(server_task, timeout=SHUTDOWN_TIMEOUT_SECONDS)
627
- except TimeoutError:
648
+ # asyncio.wait, not wait_for: wait_for's timeout path awaits the
649
+ # cancellation completing, which hangs when the stdio transport
650
+ # ignores cancellation mid-stdin-read (issue #2027). wait simply
651
+ # returns after the timeout, leaving the straggler abandoned.
652
+ stopped, _ = await asyncio.wait(
653
+ {server_task}, timeout=SHUTDOWN_TIMEOUT_SECONDS
654
+ )
655
+ if server_task in stopped:
656
+ try:
657
+ server_task.result()
658
+ except asyncio.CancelledError:
659
+ # Expected: we just cancelled server_task above; swallow
660
+ # its CancelledError so shutdown can proceed to cleanup.
661
+ pass
662
+ else:
628
663
  logger.warning("Server did not stop within timeout")
629
- except asyncio.CancelledError:
630
- # Expected: we just cancelled server_task above; swallow its
631
- # CancelledError so shutdown can proceed to cleanup.
632
- pass
633
664
  elif server_task in done:
634
665
  # Server task finished on its own (no shutdown signal). Re-raise any
635
666
  # exception it captured so a hard startup failure surfaces as a
@@ -696,6 +727,20 @@ def _signal_handler(signum: int, frame: Any) -> None:
696
727
  sys.exit(1)
697
728
 
698
729
  _shutdown_in_progress = True
730
+
731
+ if _force_exit_on_shutdown:
732
+ # The SDK's stdio teardown hangs if a stdin read is in flight when
733
+ # the server task is cancelled: the CancelledError gets stuck being
734
+ # thrown into stdio_server's task group while the reader thread is
735
+ # parked in a blocking stdin read, so asyncio.run never returns
736
+ # (issue #2027 — leftover stdio instances that never exit).
737
+ # Guarantee the process dies even if the graceful path is stuck.
738
+ # Armed BEFORE the log line below: logging writes to stderr, and a
739
+ # full stderr pipe would block this handler before it armed anything.
740
+ watchdog = threading.Timer(_SHUTDOWN_WATCHDOG_SECONDS, _shutdown_watchdog_fire)
741
+ watchdog.daemon = True
742
+ watchdog.start()
743
+
699
744
  logger.info(f"Received {sig_name}, initiating graceful shutdown...")
700
745
 
701
746
  # Signal the shutdown event if we have an event loop
@@ -720,6 +765,61 @@ async def _run_with_graceful_shutdown() -> None:
720
765
  await _run_with_shutdown(_get_mcp().run_async(show_banner=_get_show_banner()))
721
766
 
722
767
 
768
+ def _force_exit(code: int) -> NoReturn:
769
+ """Exit the stdio process without interpreter finalization (issue #2027).
770
+
771
+ The MCP SDK's stdio transport reads stdin through anyio's worker-thread
772
+ pool, so a daemon thread is parked in a blocking stdin read holding the
773
+ ``BufferedReader`` lock whenever no message is in flight. If the process
774
+ exits while that read is pending — SIGTERM, or the client vanishing
775
+ mid-read — interpreter finalization tries to close stdin, cannot acquire
776
+ the lock, and CPython aborts: ``Fatal Python error: _enter_buffered_busy``
777
+ → SIGABRT (a crash dialog on macOS). The reader lives in the SDK, so the
778
+ entry point neutralizes it instead: flush what matters, then skip
779
+ finalization entirely. By this point ``_run_with_shutdown`` has already
780
+ run resource cleanup, and a stdio server persists nothing at exit.
781
+
782
+ The flush is best-effort and time-bounded in a daemon thread: if the
783
+ client stopped draining stdout/stderr, the SDK's writer thread can be
784
+ blocked on the full pipe holding the buffered-writer lock, and flushing
785
+ inline would wait on that same lock forever — turning the hard-exit
786
+ path (including the shutdown watchdog) back into a hang. ``os._exit``
787
+ runs unconditionally once the bounded wait elapses.
788
+ """
789
+
790
+ def _best_effort_flush() -> None:
791
+ logging.shutdown()
792
+ for stream in (sys.stdout, sys.stderr):
793
+ try:
794
+ stream.flush()
795
+ except Exception: # nothing actionable at exit
796
+ pass
797
+
798
+ try:
799
+ flusher = threading.Thread(target=_best_effort_flush, daemon=True)
800
+ flusher.start()
801
+ flusher.join(timeout=1.0)
802
+ finally:
803
+ os._exit(code)
804
+
805
+
806
+ def _shutdown_watchdog_fire() -> None:
807
+ """Hard-deadline fallback for a stuck graceful shutdown (issue #2027).
808
+
809
+ The warning is fire-and-forget in a daemon thread: logging writes to
810
+ stderr, and a full stderr pipe blocking the warning would block the
811
+ watchdog itself — the very condition it exists to escape.
812
+ """
813
+ threading.Thread(
814
+ target=lambda: logger.warning(
815
+ "Graceful shutdown did not complete within %.0fs; forcing exit",
816
+ _SHUTDOWN_WATCHDOG_SECONDS,
817
+ ),
818
+ daemon=True,
819
+ ).start()
820
+ _force_exit(0)
821
+
822
+
723
823
  # CLI entry point (for pyproject.toml) - use FastMCP's built-in runner
724
824
  def main() -> None:
725
825
  """Run server via CLI using FastMCP's stdio transport."""
@@ -758,7 +858,28 @@ def main() -> None:
758
858
  # Best-effort: failure logs a warning but doesn't block MCP startup.
759
859
  _maybe_spawn_settings_sidecar()
760
860
 
761
- _run_entrypoint(_run_with_graceful_shutdown(), "Server")
861
+ # Route every exit through _force_exit: letting interpreter finalization
862
+ # run aborts when the SDK's stdin reader thread still holds the stdin
863
+ # lock. _run_entrypoint always leaves via sys.exit; the BaseException
864
+ # arm covers hard-stop paths (e.g. the re-raised CancelledError from the
865
+ # #1544 handling) that would otherwise bypass the force-exit. It also
866
+ # lets the signal handler arm the shutdown watchdog (stdio only).
867
+ global _force_exit_on_shutdown
868
+ _force_exit_on_shutdown = True
869
+ code = 1 # abnormal termination unless an exit says otherwise
870
+ try:
871
+ _run_entrypoint(_run_with_graceful_shutdown(), "Server")
872
+ code = 0
873
+ except SystemExit as exc:
874
+ if isinstance(exc.code, int):
875
+ code = exc.code
876
+ elif exc.code is None:
877
+ code = 0
878
+ # any other code (an error-message string) keeps the default 1
879
+ except BaseException:
880
+ logger.error("Server terminated abnormally", exc_info=True)
881
+ finally:
882
+ _force_exit(code)
762
883
 
763
884
 
764
885
  def _maybe_spawn_settings_sidecar() -> None:
@@ -116,6 +116,14 @@ class ErrorCode(StrEnum):
116
116
  # backup directory is unusable). Fail-closed: nothing was changed.
117
117
  BACKUP_CAPTURE_FAILED = "BACKUP_CAPTURE_FAILED"
118
118
 
119
+ # Entity visibility enforce mode (#2015). A read was refused because the
120
+ # opt-in visibility filter's enforce mode is active and the request would
121
+ # surface an entity the filter hides — either the tool's output references a
122
+ # hidden entity, or the tool's output cannot be scanned (screenshots, sandbox
123
+ # code). Direct reads of a hidden entity are concealed as ENTITY_NOT_FOUND
124
+ # instead; this code marks the refuse-on-contact case. No data was returned.
125
+ ENTITY_VISIBILITY_ENFORCED = "ENTITY_VISIBILITY_ENFORCED"
126
+
119
127
 
120
128
  # Default suggestions for common error codes
121
129
  DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
@@ -14,9 +14,10 @@ axis from the global enable/disable in the settings UI:
14
14
 
15
15
  The single source of truth travels **in-band**: :class:`LlmExposureMiddleware`
16
16
  stamps every ``tools/list`` entry with
17
- ``_meta.ha_mcp = {"llm_api_exposed": bool, "pinned": bool}`` so the component
18
- (one more loopback MCP client) filters on data that can never drift from the
19
- server's settings, with zero extra round-trips. Stamping re-reads the
17
+ ``_meta.ha_mcp = {"llm_api_exposed": bool, "pinned": bool, "policy": {...}}``
18
+ so the component (one more loopback MCP client) filters on data that can never
19
+ drift from the server's settings, with zero extra round-trips. The ``policy``
20
+ block reports the serving server's gating state (#1990 — see META_POLICY_KEY). Stamping re-reads the
20
21
  persisted settings behind a short coalescing cache (2s TTL), so settings-UI
21
22
  changes apply on the agent's next conversation turn without a restart.
22
23
 
@@ -31,7 +32,7 @@ from __future__ import annotations
31
32
 
32
33
  import logging
33
34
  import time
34
- from collections.abc import Mapping, Sequence
35
+ from collections.abc import Callable, Mapping, Sequence
35
36
  from typing import TYPE_CHECKING, Any
36
37
 
37
38
  from fastmcp.server.middleware import Middleware
@@ -50,6 +51,15 @@ META_NAMESPACE = "ha_mcp"
50
51
  META_EXPOSED_KEY = "llm_api_exposed"
51
52
  META_PINNED_KEY = "pinned"
52
53
 
54
+ # Serving-server policy/identity block stamped alongside the per-tool keys
55
+ # (#1990). A client (or a debugging agent) reading tools/list can see the
56
+ # ACTUAL gating state of the server answering this connection — configured
57
+ # flag, live middleware, and rule count — plus the deployment mode. Without
58
+ # this, a client pointed at a different server than the one the user
59
+ # configured rules on fails silently: calls execute ungated and nothing
60
+ # anywhere says "this server has zero rules".
61
+ META_POLICY_KEY = "policy"
62
+
53
63
  # Key in tool_config.json holding the user's per-tool overrides
54
64
  # ({tool_name: bool}). Sparse on purpose: only tools the user explicitly
55
65
  # flipped are stored, so tools added by future releases keep getting their
@@ -153,9 +163,17 @@ class LlmExposureMiddleware(Middleware):
153
163
  is hidden or altered for regular MCP clients.
154
164
  """
155
165
 
156
- def __init__(self) -> None:
157
- """Initialize the short-lived settings cache."""
166
+ def __init__(self, policy_live: Callable[[], bool] | None = None) -> None:
167
+ """Initialize the short-lived settings cache.
168
+
169
+ ``policy_live`` reports whether the gating middleware/queue are
170
+ actually wired on this server (they only wire at startup); the
171
+ stamp carries it so "configured but not enforcing until restart"
172
+ is visible on the wire.
173
+ """
158
174
  self._cache: tuple[float, dict[str, bool], set[str]] | None = None
175
+ self._policy_live = policy_live
176
+ self._policy_cache: tuple[float, dict[str, Any]] | None = None
159
177
 
160
178
  def _current_settings(self) -> tuple[dict[str, bool], set[str]]:
161
179
  """Return (overrides, pinned) with a short TTL over the file reads."""
@@ -192,6 +210,47 @@ class LlmExposureMiddleware(Middleware):
192
210
  self._cache = (now, overrides, pinned)
193
211
  return overrides, pinned
194
212
 
213
+ def _policy_block(self) -> dict[str, Any]:
214
+ """Serving-server policy/identity block (TTL-cached like the overrides).
215
+
216
+ Best-effort: a failure to read settings or the policy file stamps
217
+ conservative values (enabled=False / rules=0) rather than breaking
218
+ tools/list — the block is diagnostic metadata, not enforcement.
219
+ """
220
+ now = time.monotonic()
221
+ if (
222
+ self._policy_cache is not None
223
+ and now - self._policy_cache[0] < _OVERRIDES_TTL_SECONDS
224
+ ):
225
+ return self._policy_cache[1]
226
+ from ._version import is_embedded, is_running_in_addon
227
+
228
+ block: dict[str, Any] = {
229
+ "enabled": False,
230
+ "live": bool(self._policy_live()) if self._policy_live else False,
231
+ "rules": 0,
232
+ "deployment": (
233
+ "embedded"
234
+ if is_embedded()
235
+ else ("addon" if is_running_in_addon() else "standalone")
236
+ ),
237
+ }
238
+ try:
239
+ from .config import get_global_settings
240
+
241
+ block["enabled"] = bool(get_global_settings().enable_tool_security_policies)
242
+ except Exception:
243
+ logger.debug("policy stamp: settings read failed", exc_info=True)
244
+ try:
245
+ from .policy.persistence import load_policy
246
+ from .utils.data_paths import get_data_dir
247
+
248
+ block["rules"] = len(load_policy(get_data_dir()).rules)
249
+ except Exception:
250
+ logger.debug("policy stamp: policy read failed", exc_info=True)
251
+ self._policy_cache = (now, block)
252
+ return block
253
+
195
254
  async def on_list_tools(
196
255
  self,
197
256
  context: MiddlewareContext[mt.ListToolsRequest],
@@ -200,6 +259,7 @@ class LlmExposureMiddleware(Middleware):
200
259
  """Stamp ``_meta.ha_mcp`` on every tool in the list result."""
201
260
  tools = await call_next(context)
202
261
  overrides, pinned = self._current_settings()
262
+ policy_block = self._policy_block()
203
263
 
204
264
  stamped: list[Tool] = []
205
265
  for tool in tools:
@@ -209,6 +269,7 @@ class LlmExposureMiddleware(Middleware):
209
269
  tool.name, tool.tags or set(), overrides
210
270
  )
211
271
  namespace[META_PINNED_KEY] = tool.name in pinned
272
+ namespace[META_POLICY_KEY] = policy_block
212
273
  meta[META_NAMESPACE] = namespace
213
274
  stamped.append(tool.model_copy(update={"meta": meta}))
214
275
  return stamped
@@ -11,6 +11,7 @@ from pydantic import ValidationError
11
11
  from starlette.requests import Request
12
12
  from starlette.responses import JSONResponse
13
13
 
14
+ from ..utils.config_write_lock import config_write_guard
14
15
  from .approval_queue import ApprovalQueue
15
16
  from .model import Policy
16
17
  from .persistence import load_policy, save_policy
@@ -86,24 +87,29 @@ async def _put_config(
86
87
  # Optimistic concurrency: reject if the on-disk version moved
87
88
  # between this caller's GET and PUT. Returns the current policy
88
89
  # so the client can rebase if it wants to retry.
89
- current = load_policy(data_dir)
90
- if new_policy.version != current.version:
91
- return JSONResponse(
92
- {
93
- "error": "policy version mismatch — reload before saving",
94
- "current_version": current.version,
95
- "current_policy": current.model_dump(mode="json"),
96
- },
97
- status_code=409,
98
- )
99
- save_policy(data_dir, new_policy)
100
- # Drop the remember-cache only when rules actually changed.
101
- # Editing just wait_seconds / approval_ttl_minutes shouldn't
102
- # invalidate in-flight remembered approvals; only a rule change
103
- # could make a previously-approved call now want a different
104
- # outcome.
105
- if current.rules != new_policy.rules:
106
- queue.clear_remember_cache()
90
+ # Serialize the version-check + save against the developer tool
91
+ # (set_policy / set_tool) AND against other processes (the stdio
92
+ # sidecar runs this same handler in its own process) so a concurrent
93
+ # writer can't slip between the read and the write and lose an update.
94
+ async with config_write_guard():
95
+ current = load_policy(data_dir)
96
+ if new_policy.version != current.version:
97
+ return JSONResponse(
98
+ {
99
+ "error": "policy version mismatch — reload before saving",
100
+ "current_version": current.version,
101
+ "current_policy": current.model_dump(mode="json"),
102
+ },
103
+ status_code=409,
104
+ )
105
+ save_policy(data_dir, new_policy)
106
+ # Drop the remember-cache only when rules actually changed.
107
+ # Editing just wait_seconds / approval_ttl_minutes shouldn't
108
+ # invalidate in-flight remembered approvals; only a rule change
109
+ # could make a previously-approved call now want a different
110
+ # outcome.
111
+ if current.rules != new_policy.rules:
112
+ queue.clear_remember_cache()
107
113
  return JSONResponse({"saved": True, "version": new_policy.version + 1})
108
114
 
109
115
 
@@ -35,6 +35,29 @@ PROXY_META_TOOLS = frozenset(
35
35
  }
36
36
  )
37
37
 
38
+ # ha_dev_manage_server actions that MANAGE the approval queue itself.
39
+ # Gating these deadlocks by construction: with a wildcard (or
40
+ # ha_dev_manage_server) rule in place, an MCP-only "approve" call would
41
+ # itself require approval — creating a second pending entry instead of
42
+ # deciding the first, so nothing can ever be approved through the tool.
43
+ # Only the queue-management actions are exempt; update_source / restart
44
+ # remain gateable like any other high-stakes action.
45
+ _APPROVAL_MANAGEMENT_TOOL = "ha_dev_manage_server"
46
+ _APPROVAL_MANAGEMENT_ACTIONS = frozenset({"list_pending", "approve", "deny"})
47
+
48
+
49
+ def _is_approval_management(name: str, args: dict[str, Any]) -> bool:
50
+ """True for dev-tool calls that manage the approval queue itself."""
51
+ return (
52
+ name == _APPROVAL_MANAGEMENT_TOOL
53
+ and args.get("action") in _APPROVAL_MANAGEMENT_ACTIONS
54
+ )
55
+
56
+
57
+ def _passes_ungated(name: str, args: dict[str, Any]) -> bool:
58
+ """Calls that must bypass gating: proxy meta-tools + queue management."""
59
+ return name in PROXY_META_TOOLS or _is_approval_management(name, args)
60
+
38
61
 
39
62
  class PolicyMiddleware(Middleware):
40
63
  """Gate tool calls against a Policy, blocking with progress heartbeats."""
@@ -78,7 +101,7 @@ class PolicyMiddleware(Middleware):
78
101
  name = context.message.name
79
102
  args = context.message.arguments or {}
80
103
 
81
- if name in PROXY_META_TOOLS:
104
+ if _passes_ungated(name, args):
82
105
  return await call_next(context)
83
106
 
84
107
  if evaluate(name, args, policy) != Verdict.REQUIRE_APPROVAL:
@@ -16,6 +16,14 @@ PredicateOp = Literal[
16
16
  "eq", "neq", "in", "not_in", "regex", "contains", "exists", "gt", "lt"
17
17
  ]
18
18
 
19
+ # Schema generation of the persisted policy file. Version 2 = ANY-match
20
+ # condition semantics (PR #1993): each UI condition is its own rule; a rule's
21
+ # predicates AND together (a condition with sub-parameters). Files WITHOUT the
22
+ # marker were written under the pre-#1993 editor, which packed every condition
23
+ # into one AND-ed rule — ``persistence.migrate_policy_any_semantics`` splits
24
+ # those once at startup and stamps the file.
25
+ POLICY_SCHEMA_VERSION = 2
26
+
19
27
 
20
28
  class Predicate(BaseModel):
21
29
  """Single condition on a tool call's arguments (e.g. args.domain in [...])."""
@@ -103,6 +111,9 @@ class Policy(BaseModel):
103
111
  approval_ttl_minutes: int = Field(default=5, ge=1, le=60)
104
112
  rules: list[Rule] = Field(default_factory=list)
105
113
  version: int = Field(default=0, ge=0)
114
+ # ANY-match schema marker (see POLICY_SCHEMA_VERSION). Detection of
115
+ # unmigrated files reads the RAW json (this default would mask it).
116
+ schema_version: int = Field(default=POLICY_SCHEMA_VERSION, ge=1)
106
117
 
107
118
  @model_validator(mode="after")
108
119
  def _wait_must_be_less_than_ttl(self) -> "Policy":
@@ -0,0 +1,125 @@
1
+ """Atomic load/save for tool_policy.json (mirrors tool_config.json pattern in settings_ui/__init__.py)."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from .model import Policy, Rule
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ POLICY_FILENAME = "tool_policy.json"
16
+
17
+
18
+ def load_policy(data_dir: Path) -> Policy:
19
+ path = data_dir / POLICY_FILENAME
20
+ if not path.exists():
21
+ return Policy()
22
+ try:
23
+ raw = json.loads(path.read_text(encoding="utf-8"))
24
+ except json.JSONDecodeError as e:
25
+ raise ValueError(f"tool_policy.json is not valid JSON: {e}") from e
26
+ try:
27
+ return Policy.model_validate(raw)
28
+ except ValidationError as e:
29
+ raise ValueError(f"tool_policy.json failed schema validation: {e}") from e
30
+
31
+
32
+ def migrate_policy_any_semantics(data_dir: Path) -> bool:
33
+ """One-time migration of a pre-ANY policy file (PR #1993). Returns True on write.
34
+
35
+ The pre-#1993 editor packed every UI condition into ONE rule with the
36
+ predicates AND-ed ("require approval when ALL conditions match"). The
37
+ editor now writes one rule per condition and the UI reads "ANY condition
38
+ matches" — so an untouched old file would silently enforce AND while the
39
+ UI claims ANY. This splits every multi-predicate rule of an UNSTAMPED
40
+ file into one single-predicate rule each (the explicit, documented
41
+ breaking change: old AND conditions become OR, the more-restrictive
42
+ direction) and stamps ``schema_version`` so the migration never re-runs —
43
+ post-upgrade multi-predicate rules (a condition with AND-ed
44
+ sub-parameters, hand-authored or via future UI) are left intact.
45
+
46
+ Detection reads the RAW json: the Policy model defaults
47
+ ``schema_version`` to current, which would mask an unstamped file.
48
+ Missing or corrupt files are left alone (load_policy surfaces corruption).
49
+
50
+ The whole read-split-save runs under ``config_file_lock()`` so a
51
+ concurrent writer in another process (the stdio settings sidecar) can't
52
+ interleave with the migration's read-modify-write. The blocking lock is
53
+ taken inline: this runs once during server construction, before anything
54
+ is served. The sync entry points construct pre-loop; OAuth/OIDC construct
55
+ on a just-started ``asyncio.run`` loop (``_run_oauth_server`` /
56
+ ``_run_oidc_server``) where the sync constructor cannot await — but no
57
+ other task is scheduled on that loop yet, so a held lock can only delay
58
+ startup, never stall a served client.
59
+ """
60
+ from ..utils.config_write_lock import config_file_lock
61
+
62
+ with config_file_lock(data_dir):
63
+ return _migrate_policy_any_semantics_locked(data_dir)
64
+
65
+
66
+ def _migrate_policy_any_semantics_locked(data_dir: Path) -> bool:
67
+ path = data_dir / POLICY_FILENAME
68
+ try:
69
+ raw = json.loads(path.read_text(encoding="utf-8"))
70
+ except FileNotFoundError:
71
+ return False
72
+ except (OSError, json.JSONDecodeError):
73
+ logger.warning("policy migration: cannot read %s; leaving as-is", path)
74
+ return False
75
+ if not isinstance(raw, dict) or raw.get("schema_version") is not None:
76
+ return False
77
+ try:
78
+ policy = Policy.model_validate(raw)
79
+ except ValidationError:
80
+ logger.warning("policy migration: %s failed validation; leaving as-is", path)
81
+ return False
82
+ new_rules: list[Rule] = []
83
+ split = 0
84
+ for rule in policy.rules:
85
+ if len(rule.when) > 1:
86
+ split += 1
87
+ new_rules.extend(
88
+ Rule(
89
+ tool_name=rule.tool_name,
90
+ when=[predicate],
91
+ remember_minutes=rule.remember_minutes,
92
+ )
93
+ for predicate in rule.when
94
+ )
95
+ else:
96
+ new_rules.append(rule)
97
+ save_policy(data_dir, policy.model_copy(update={"rules": new_rules}))
98
+ logger.info(
99
+ "Migrated %s to ANY-match condition semantics: split %d multi-condition "
100
+ "rule(s) into one rule per condition (%d -> %d rules) and stamped "
101
+ "schema_version. Conditions that previously ALL had to match now EACH "
102
+ "gate on their own.",
103
+ path,
104
+ split,
105
+ len(policy.rules),
106
+ len(new_rules),
107
+ )
108
+ return True
109
+
110
+
111
+ def save_policy(data_dir: Path, policy: Policy) -> None:
112
+ # Bump version on every save so optimistic-concurrency callers can
113
+ # detect mid-flight edits (PUT /api/policy/config 409s when the
114
+ # caller's payload version != on-disk version).
115
+ bumped = policy.model_copy(update={"version": policy.version + 1})
116
+ data_dir.mkdir(parents=True, exist_ok=True)
117
+ path = data_dir / POLICY_FILENAME
118
+ fd, tmp_path = tempfile.mkstemp(prefix=f".{POLICY_FILENAME}.", dir=data_dir)
119
+ try:
120
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
121
+ json.dump(bumped.model_dump(mode="json"), f, indent=2, sort_keys=True)
122
+ os.replace(tmp_path, path)
123
+ except Exception:
124
+ Path(tmp_path).unlink(missing_ok=True)
125
+ raise