ha-mcp-dev 7.14.2.dev2128__tar.gz → 7.14.2.dev2131__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 (190) hide show
  1. {ha_mcp_dev-7.14.2.dev2128/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.14.2.dev2131}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/pyproject.toml +8 -1
  3. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/__main__.py +30 -8
  4. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_yaml_read.py +217 -43
  5. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/usage_logger.py +26 -0
  6. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  7. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/LICENSE +0 -0
  8. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/MANIFEST.in +0 -0
  9. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/README.md +0 -0
  10. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/setup.cfg +0 -0
  11. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/__init__.py +0 -0
  12. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/_version.py +0 -0
  14. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/auth/provider.py +0 -0
  17. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/backup_manager.py +0 -0
  18. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/browser_landing.py +0 -0
  19. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/client/supervisor_client.py +0 -0
  22. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  26. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  27. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  28. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  29. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  30. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
  31. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/errors.py +0 -0
  32. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/llm_exposure.py +0 -0
  33. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/__init__.py +0 -0
  34. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/approval_queue.py +0 -0
  35. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/evaluator.py +0 -0
  36. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/handlers.py +0 -0
  37. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/middleware.py +0 -0
  38. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/model.py +0 -0
  39. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/persistence.py +0 -0
  40. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/policy/value_sources.py +0 -0
  41. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/py.typed +0 -0
  42. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/read_only.py +0 -0
  43. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  44. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  45. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  46. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  47. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  48. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  49. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  50. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  51. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  52. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  53. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  54. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  55. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
  56. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  57. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
  58. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  59. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  60. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  61. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  62. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  63. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  64. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  65. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
  66. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  67. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  68. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/server.py +0 -0
  69. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/__init__.py +0 -0
  70. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  71. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
  72. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  73. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  74. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  75. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
  76. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  77. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_persistence.py +0 -0
  78. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  79. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_theme.py +0 -0
  80. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  81. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  82. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/locales/de.json +0 -0
  83. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/locales/en.json +0 -0
  84. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
  85. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/locales/zh-Hans.json +0 -0
  86. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/settings.css +0 -0
  87. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/settings.html +0 -0
  88. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/settings_ui/settings.js +0 -0
  89. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/smoke_test.py +0 -0
  90. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  91. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/strict_bps.py +0 -0
  92. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/__init__.py +0 -0
  93. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/auto_backup.py +0 -0
  94. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/backup.py +0 -0
  95. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  96. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/component_api.py +0 -0
  97. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/component_config_reads.py +0 -0
  98. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/component_devices.py +0 -0
  99. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/component_registries.py +0 -0
  100. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
  101. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  102. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/device_control.py +0 -0
  103. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/enhanced.py +0 -0
  104. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/helpers.py +0 -0
  105. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/radio/__init__.py +0 -0
  106. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/radio/base.py +0 -0
  107. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/radio/matter.py +0 -0
  108. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/radio/thread.py +0 -0
  109. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  110. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/radio/zwave.py +0 -0
  111. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/reference_validator.py +0 -0
  112. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/registry.py +0 -0
  113. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  114. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  115. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  116. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  117. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  118. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  119. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  120. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  121. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  122. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  123. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_addons.py +0 -0
  124. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_areas.py +0 -0
  125. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  126. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  127. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_calendar.py +0 -0
  128. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_camera.py +0 -0
  129. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_categories.py +0 -0
  130. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_code.py +0 -0
  131. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  132. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  133. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  134. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  135. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  136. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  137. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_dev.py +0 -0
  138. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_energy.py +0 -0
  139. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_entities.py +0 -0
  140. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  141. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_groups.py +0 -0
  142. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_hacs.py +0 -0
  143. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_history.py +0 -0
  144. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_integrations.py +0 -0
  145. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_labels.py +0 -0
  146. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_radio.py +0 -0
  147. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_registry.py +0 -0
  148. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_resources.py +0 -0
  149. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_search.py +0 -0
  150. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_service.py +0 -0
  151. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_services.py +0 -0
  152. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_system.py +0 -0
  153. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_themes.py +0 -0
  154. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_todo.py +0 -0
  155. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_traces.py +0 -0
  156. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_updates.py +0 -0
  157. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_utility.py +0 -0
  158. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  159. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  160. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/tools_zones.py +0 -0
  161. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/util_helpers.py +0 -0
  162. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/tools/validation_middleware.py +0 -0
  163. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/transforms/__init__.py +0 -0
  164. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/transforms/categorized_search.py +0 -0
  165. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  166. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/transport_security.py +0 -0
  167. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/update_check.py +0 -0
  168. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/__init__.py +0 -0
  169. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/config_hash.py +0 -0
  170. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/config_write_lock.py +0 -0
  171. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/data_paths.py +0 -0
  172. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/domain_handlers.py +0 -0
  173. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  174. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  175. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/operation_manager.py +0 -0
  176. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/python_sandbox.py +0 -0
  177. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/utils/skill_loader.py +0 -0
  178. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/visibility/__init__.py +0 -0
  179. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/visibility/enforcement.py +0 -0
  180. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/visibility/model.py +0 -0
  181. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/visibility/persistence.py +0 -0
  182. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp/visibility/resolver.py +0 -0
  183. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  184. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  185. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  186. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  187. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  188. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/tests/__init__.py +0 -0
  189. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/tests/test_constants.py +0 -0
  190. {ha_mcp_dev-7.14.2.dev2128 → ha_mcp_dev-7.14.2.dev2131}/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.dev2128
3
+ Version: 7.14.2.dev2131
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.dev2128"
7
+ version = "7.14.2.dev2131"
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
@@ -199,6 +199,13 @@ asyncio_mode = "auto"
199
199
 
200
200
  [dependency-groups]
201
201
  dev = [
202
+ # Real aiohttp so tests can pin the ClientTimeout defaults the custom
203
+ # component's relay and the webhook-proxy addon rely on (both are stubbed
204
+ # in unit tests; nothing else would catch stub drift). NOT a runtime
205
+ # dependency — src/ never imports aiohttp; HA core provides it in-process.
206
+ # Floor = the aiohttp HA core 2026.6 pins (2026.7 pins 3.14.1, 2026.8.dev
207
+ # pins 3.14.3), so the version tested here is one a supported HA runs.
208
+ "aiohttp>=3.13.5",
202
209
  "build>=1.2.2",
203
210
  "docker>=7.1.0",
204
211
  "httpx2>=2.4.0",
@@ -478,14 +478,36 @@ class ProbeAccessLogFilter(logging.Filter):
478
478
  return not (status == 405 and path == self._mcp_path)
479
479
 
480
480
 
481
- def _setup_logging(log_level_str: str, force: bool = False) -> None:
482
- """Configure root logger with consistent timestamp format."""
483
- logging.basicConfig(
484
- level=getattr(logging, log_level_str),
485
- format="%(asctime)s %(name)s %(levelname)s: %(message)s",
486
- datefmt=_LOG_DATE_FORMAT,
487
- force=force,
488
- )
481
+ def _setup_logging(log_level_str: str, force: bool = True) -> None:
482
+ """Configure root logger with consistent timestamp format.
483
+
484
+ ``force`` defaults to True so the reconfiguration is deterministic: with
485
+ ANY root handler present, ``basicConfig`` is a silent no-op — neither the
486
+ console handler nor the level is applied. Since
487
+ ``preserve_startup_collector`` detaches the collector before
488
+ ``basicConfig`` runs, the default guards against FOREIGN root handlers —
489
+ a test runner's or a dependency's. At the two call sites that use the
490
+ default (``main`` and ``_setup_standard_mode``) the only root handler
491
+ attached anywhere in ``src/`` is the collector, which the wrapper detaches
492
+ first — so today the default is defensive rather than load-bearing. The
493
+ OAuth/OIDC entry points pass ``force=True`` explicitly and never exercise
494
+ the default.
495
+
496
+ The historical standard-mode bug was ``usage_logger``'s
497
+ ``StartupLogCollector`` (attached to root at import time) triggering that
498
+ same no-op; the wrapper now detaches it for the duration — keeping it out
499
+ of the sweep ``force`` performs (which removes *and closes* every root
500
+ handler) so ``ha_report_issue`` keeps its startup diagnostics.
501
+ """
502
+ from ha_mcp.utils.usage_logger import preserve_startup_collector
503
+
504
+ with preserve_startup_collector():
505
+ logging.basicConfig(
506
+ level=getattr(logging, log_level_str),
507
+ format="%(asctime)s %(name)s %(levelname)s: %(message)s",
508
+ datefmt=_LOG_DATE_FORMAT,
509
+ force=force,
510
+ )
489
511
  logging.getLogger("mcp.server.streamable_http").addFilter(
490
512
  StatelessSessionLogFilter()
491
513
  )
@@ -17,6 +17,7 @@ back door.
17
17
 
18
18
  import asyncio
19
19
  import logging
20
+ import re
20
21
  from typing import Annotated, Any
21
22
 
22
23
  from fastmcp.exceptions import ToolError
@@ -43,10 +44,46 @@ logger = logging.getLogger(__name__)
43
44
  # fnmatches on the file name); this only decides single-file vs. glob.
44
45
  _GLOB_METACHARS = ("*", "?", "[")
45
46
 
47
+ # The metacharacters that match their own literal names, and so are pattern
48
+ # syntax rather than a plausible spelling of a file name the caller means
49
+ # exactly. A bracket class is the ambiguous one and is deliberately absent.
50
+ _PATTERN_ONLY_METACHARS = ("*", "?")
46
51
 
47
- def _has_glob(file: str) -> bool:
48
- """True if ``file`` carries an fnmatch wildcard and must be expanded."""
49
- return any(char in file for char in _GLOB_METACHARS)
52
+ # Wraps each metacharacter in a bracket class so fnmatch reads it literally.
53
+ # Deliberately not glob.escape: it splits a drive off first and returns that
54
+ # part UNESCAPED, and the split is ntpath on a Windows-hosted server - where a
55
+ # name like \\weird[a].yaml is taken for a drive in its entirety and comes back
56
+ # with its bracket intact. The one host-dependent step in a path that is
57
+ # otherwise POSIX and config-relative.
58
+ _METACHAR_RE = re.compile(r"([*?\[])")
59
+
60
+
61
+ def _has_glob(name: str) -> bool:
62
+ """True if ``name`` carries an fnmatch wildcard and must be expanded.
63
+
64
+ Takes the file name rather than the whole path: the component resolves
65
+ directories literally, so a metacharacter in a folder name belongs to that
66
+ folder's name and is not a pattern.
67
+ """
68
+ return any(char in name for char in _GLOB_METACHARS)
69
+
70
+
71
+ def _names_a_file(pattern: str) -> bool:
72
+ """True if the request can be read as naming one file rather than a class.
73
+
74
+ ``*`` and ``?`` are pattern syntax: they match their own literal names, so
75
+ a caller who wrote one wrote a pattern. A bracket class alone is ambiguous,
76
+ since such a name is legal on the filesystem and cannot match itself.
77
+
78
+ One predicate for two decisions that must not diverge - whether a path the
79
+ literal lookup returned counts as explicitly named, and whether the absence
80
+ of such a path is worth a warning. Honouring it in the warning alone would
81
+ leave a file genuinely called ``[ab]*.yaml`` strict, so one failed read of
82
+ it would discard every match the pattern half had already found: the shape
83
+ removed for ``*.yaml`` when provenance moved into the resolver, reachable
84
+ again through the other lookup.
85
+ """
86
+ return not any(char in pattern for char in _PATTERN_ONLY_METACHARS)
50
87
 
51
88
 
52
89
  def _call_failed(service: str, context: dict[str, Any]) -> None:
@@ -83,16 +120,18 @@ def _failure_text(payload: dict[str, Any]) -> str:
83
120
 
84
121
 
85
122
  def _parse_error_result(
86
- target: str, yaml_path: str, parse_error: str, *, is_glob: bool
123
+ target: str, yaml_path: str, parse_error: str, *, is_expanded: bool
87
124
  ) -> tuple[None, str]:
88
- """Decide a file the component could not parse: raise for a single target.
89
-
90
- A single explicit target has no siblings to salvage, so a parse failure
91
- raises rather than soft-degrading to a warning that reads as "key absent".
92
- Under a glob it stays a per-file warning so one broken file does not sink
93
- the whole search.
125
+ """Decide a file the component could not parse: raise for a named target.
126
+
127
+ The explicitly named target raises rather than soft-degrading to a warning
128
+ that reads as "key absent" - it raises even when expansion turned up
129
+ siblings alongside it, discarding their results, because a named file that
130
+ cannot be parsed is the caller's question, not a stray unreadable neighbour.
131
+ An expanded target stays a per-file warning so one broken file does not
132
+ sink the whole search.
94
133
  """
95
- if not is_glob:
134
+ if not is_expanded:
96
135
  raise_tool_error(
97
136
  create_error_response(
98
137
  ErrorCode.CONFIG_INVALID,
@@ -104,12 +143,39 @@ def _parse_error_result(
104
143
  return None, f"{target} was not searched: {parse_error}."
105
144
 
106
145
 
146
+ def _read_exception_result(
147
+ target: str, error: BaseException, *, is_expanded: bool
148
+ ) -> tuple[None, str]:
149
+ """Decide a read that blew up outright.
150
+
151
+ An expanded target degrades to a warning so one unreadable file cannot sink
152
+ the search; the explicitly named target raises, as it does for every other
153
+ failure class in :func:`_evaluate_read`. The error is re-raised as it came
154
+ rather than wrapped, so the tool's own handler classifies it by type - the
155
+ other classes have no live exception to preserve and build a structured
156
+ :class:`ToolError` instead.
157
+
158
+ Anything that is not an ``Exception`` propagates whatever the target's
159
+ provenance - this is the one failure class here that raises for an expanded
160
+ target too. Cancellation and shutdown are not one file being unreadable,
161
+ and absorbing them into a warning next to ``success: true`` would report a
162
+ search that was called off as one that ran and found nothing. The per-item
163
+ fan-outs in ``tools_entities`` and ``tools_search`` draw the same line.
164
+
165
+ ``str()`` is empty on a bare ``TimeoutError()``, so the class name stands
166
+ in - a warning reading "was not searched: ." names no reason at all.
167
+ """
168
+ if not is_expanded or not isinstance(error, Exception):
169
+ raise error
170
+ return None, f"{target} was not searched: {str(error) or type(error).__name__}."
171
+
172
+
107
173
  def _evaluate_read(
108
174
  response: Any,
109
175
  target: str,
110
176
  yaml_path: str,
111
177
  *,
112
- is_glob: bool,
178
+ is_expanded: bool,
113
179
  include_content: bool,
114
180
  include_parsed: bool,
115
181
  ) -> tuple[dict[str, Any] | None, str | None]:
@@ -117,37 +183,37 @@ def _evaluate_read(
117
183
 
118
184
  Three outcomes, in the order they are decided:
119
185
 
120
- * **warning** — the file could not be searched at all. Under a glob that
186
+ * **warning** — the file could not be searched at all. An expanded target
121
187
  must not sink the whole search and discard the matches already found:
122
- the glob is not restricted to ``*.yaml``, so ``packages/*`` can turn up
188
+ expansion is not restricted to ``*.yaml``, so ``packages/*`` can turn up
123
189
  a README the component refuses to read, and a permission-denied or
124
190
  non-UTF-8 ``.yaml`` lands here too. A syntactically broken file is the
125
191
  same class — reporting it as "key absent" would claim a file was
126
- inspected when it never was. A single explicit target has no siblings
127
- to salvage, so it raises instead.
192
+ inspected when it never was. The explicitly named target raises
193
+ instead, even when expansion turned up siblings alongside it.
128
194
  * **neither** — the file parsed and simply has no such key. The real
129
195
  non-match, and the whole point of a glob search.
130
196
  * **match** — the key is there.
131
197
  """
132
- # Only reachable under a glob: with a single target return_exceptions is
133
- # False, so gather itself raised.
134
198
  if isinstance(response, BaseException):
135
- return None, f"{target} was not searched: {response}."
199
+ return _read_exception_result(target, response, is_expanded=is_expanded)
136
200
 
137
201
  if not isinstance(response, dict):
138
- if is_glob:
202
+ if is_expanded:
139
203
  return None, f"{target} was not searched: unexpected read_file response."
140
204
  _call_failed("read_file", {"file": target, "yaml_path": yaml_path})
141
205
  unwrapped = unwrap_service_response(response)
142
206
 
143
207
  if not unwrapped.get("success", True):
144
- if not is_glob:
208
+ if not is_expanded:
145
209
  raise_tool_error(unwrapped)
146
210
  return None, f"{target} was not searched: {_failure_text(unwrapped)}."
147
211
 
148
212
  parse_error = unwrapped.get("parse_error")
149
213
  if parse_error:
150
- return _parse_error_result(target, yaml_path, str(parse_error), is_glob=is_glob)
214
+ return _parse_error_result(
215
+ target, yaml_path, str(parse_error), is_expanded=is_expanded
216
+ )
151
217
 
152
218
  if unwrapped.get("subtree") is None:
153
219
  return None, None
@@ -160,18 +226,10 @@ def _evaluate_read(
160
226
  return match, None
161
227
 
162
228
 
163
- async def _resolve_target_files(client: Any, file: str) -> list[str]:
164
- """Expand ``file`` into the concrete config-relative paths to read.
165
-
166
- A plain path is returned as-is (the read itself reports a missing file). A
167
- glob is expanded through the component's ``list_files``, which matches the
168
- file name only — so ``packages/*.yaml`` covers one directory level, not a
169
- nested tree.
170
- """
171
- if not _has_glob(file):
172
- return [file]
173
-
174
- directory, _, pattern = file.rpartition("/")
229
+ async def _list_files_matching(
230
+ client: Any, file: str, directory: str, pattern: str
231
+ ) -> list[str]:
232
+ """Return the non-directory paths in ``directory`` matching ``pattern``."""
175
233
  unwrapped = _unwrap_or_raise(
176
234
  await call_mcp_tools_service(
177
235
  client,
@@ -192,6 +250,113 @@ async def _resolve_target_files(client: Any, file: str) -> list[str]:
192
250
  )
193
251
 
194
252
 
253
+ def _absent_literal_warnings(
254
+ file: str, pattern: str, *, literal_found: bool, target_count: int
255
+ ) -> list[str]:
256
+ """Say so when nothing is literally named the way a bracketed request was.
257
+
258
+ Silence is what the caller reads as "your file was searched": the siblings
259
+ the class matched arrive as ``files_searched`` with nothing marking the
260
+ named file as never opened.
261
+
262
+ Two cases stay silent. A literal that was found needs no warning, and
263
+ neither does a request that :func:`_names_a_file` rejects - nobody named a
264
+ file, so nothing is missing. The literal lookup still runs for those, since
265
+ its results are merged as ordinary expansion matches; only the complaint is
266
+ dropped.
267
+ """
268
+ if literal_found or not _names_a_file(pattern):
269
+ return []
270
+ if not target_count:
271
+ # Phrased for the degenerate case rather than reporting "the 0 file(s)
272
+ # matching it as a pattern", which reads as a search that happened.
273
+ return [
274
+ f"No file is literally named {file}, and nothing matched it as a "
275
+ f"pattern either."
276
+ ]
277
+ return [
278
+ f"No file is literally named {file}; searched the {target_count} "
279
+ f"file(s) matching it as a pattern."
280
+ ]
281
+
282
+
283
+ async def _resolve_target_files(
284
+ client: Any, file: str
285
+ ) -> tuple[list[tuple[str, bool]], list[str]]:
286
+ """Expand ``file`` into ``(path, is_expanded)`` pairs plus any warnings.
287
+
288
+ ``is_expanded`` records where the path came from, and it is decided here
289
+ because only here is it known: a path produced by the pattern lookup is an
290
+ expansion, while the requested path itself, and what the escaped-literal
291
+ lookup returns for a request :func:`_names_a_file` accepts, were named
292
+ explicitly. Recovering that downstream by comparing the path against the
293
+ requested string would be wrong for ``*`` and ``?``, which match their own
294
+ literal names - a file genuinely called ``*.yaml`` would then be mistaken
295
+ for the name the caller typed and its read failure would sink the whole
296
+ search.
297
+
298
+ A plain path is returned as-is (the read itself reports a missing file). A
299
+ glob is expanded through the component's ``list_files``, which matches the
300
+ file name only, so ``packages/*.yaml`` covers one directory level rather
301
+ than a nested tree. Only the name is inspected for metacharacters: the
302
+ component resolves the directory literally, so ``pack[a]ges/svc.yaml``
303
+ names one file and is read as one rather than expanded.
304
+
305
+ A bracketed name is ambiguous: ``packages/svc[a].yaml`` is both a valid
306
+ pattern and a valid filename, and a closed bracket class is the one
307
+ metacharacter form whose pattern cannot match its own name (``fnmatch``
308
+ reads ``[a]`` as the single character ``a``, while ``*`` and ``?`` do match
309
+ themselves). Such a name is therefore looked up twice, as the pattern and
310
+ as the escaped literal, and the results are merged: the pattern alone would
311
+ report ``files_searched: 0`` for a file that exists, and the literal alone
312
+ would drop the siblings a deliberate class like ``svc[12].yaml`` is meant
313
+ to find. Both lookups run because a sibling that satisfies the class would
314
+ otherwise mask the file the caller named exactly. Only bracketed names pay
315
+ the second lookup, and it is issued concurrently with the first rather than
316
+ after it. The component likewise treats package folder names carrying
317
+ metacharacters as literal names (#1854).
318
+
319
+ The guard is any bracket, not a closed class: an unclosed or empty bracket
320
+ is literal to ``fnmatch`` and so matches its own name, which means both
321
+ lookups return it and the deduplication below is what keeps it a single
322
+ target. A listing failure on either lookup raises rather than degrading -
323
+ the directory is the same one in both calls, so a failure there is a broken
324
+ request, not one unreadable file among several.
325
+
326
+ When the literal lookup comes back empty the caller named a file that does
327
+ not exist, and the pattern's siblings would otherwise be reported as if the
328
+ named file had been searched; :func:`_absent_literal_warnings` decides what
329
+ to say about that.
330
+ """
331
+ directory, _, pattern = file.rpartition("/")
332
+ if not _has_glob(pattern):
333
+ return [(file, False)], []
334
+
335
+ if "[" not in pattern:
336
+ matched = await _list_files_matching(client, file, directory, pattern)
337
+ return [(target, True) for target in matched], []
338
+
339
+ # The two lookups are independent, so run them together rather than paying
340
+ # two sequential round-trips; the reads below fan out for the same reason.
341
+ matched, literal = await asyncio.gather(
342
+ _list_files_matching(client, file, directory, pattern),
343
+ _list_files_matching(
344
+ client, file, directory, _METACHAR_RE.sub(r"[\1]", pattern)
345
+ ),
346
+ )
347
+ # Both lookups always contribute targets; only whether a literal hit counts
348
+ # as *named* depends on the request. Under a pattern it is merged as an
349
+ # ordinary expansion match, so nobody named it and a bad read stays a
350
+ # warning.
351
+ literal_paths = set(literal)
352
+ named = literal_paths if _names_a_file(pattern) else set()
353
+ targets = sorted(set(matched) | literal_paths)
354
+ warnings = _absent_literal_warnings(
355
+ file, pattern, literal_found=bool(named), target_count=len(targets)
356
+ )
357
+ return [(target, target not in named) for target in targets], warnings
358
+
359
+
195
360
  class YamlReadTools:
196
361
  def __init__(self, client: Any) -> None:
197
362
  self._client = client
@@ -273,15 +438,18 @@ class YamlReadTools:
273
438
  try:
274
439
  await _assert_mcp_tools_available(self._client)
275
440
 
276
- is_glob = _has_glob(file)
277
- targets = await _resolve_target_files(self._client, file)
441
+ # Strictness is a property of each target, not of the request, so
442
+ # the resolver reports which targets were named rather than expanded.
443
+ targets, resolve_warnings = await _resolve_target_files(self._client, file)
278
444
 
279
445
  extra: dict[str, Any] = {"include_parsed": True} if include_parsed else {}
280
446
  # The reads are independent, so fan them out rather than paying N
281
447
  # sequential round-trips to HA — a packages glob is routinely 10+
282
448
  # files. gather preserves order, so matches stay sorted by file.
283
- # Under a glob a single read blowing up must not sink its siblings,
284
- # so exceptions come back as values to be warned about below.
449
+ # An expanded target blowing up must not sink the search, so every
450
+ # exception comes back as a value; whether it degrades to a warning
451
+ # or is re-raised is then decided per target, like every other
452
+ # failure class.
285
453
  responses = await asyncio.gather(
286
454
  *(
287
455
  call_mcp_tools_service(
@@ -289,24 +457,26 @@ class YamlReadTools:
289
457
  "read_file",
290
458
  {"path": target, "yaml_path": yaml_path, **extra},
291
459
  )
292
- for target in targets
460
+ for target, _ in targets
293
461
  ),
294
- return_exceptions=is_glob,
462
+ return_exceptions=True,
295
463
  )
296
464
 
297
465
  matches: list[dict[str, Any]] = []
298
- warnings: list[str] = []
299
- for target, response in zip(targets, responses, strict=True):
466
+ warnings: list[str] = list(resolve_warnings)
467
+ files_unreadable = 0
468
+ for (target, is_expanded), response in zip(targets, responses, strict=True):
300
469
  match, warning = _evaluate_read(
301
470
  response,
302
471
  target,
303
472
  yaml_path,
304
- is_glob=is_glob,
473
+ is_expanded=is_expanded,
305
474
  include_content=include_content,
306
475
  include_parsed=include_parsed,
307
476
  )
308
477
  if warning:
309
478
  warnings.append(warning)
479
+ files_unreadable += 1
310
480
  elif match:
311
481
  matches.append(match)
312
482
 
@@ -318,6 +488,10 @@ class YamlReadTools:
318
488
  # Separates "the glob matched no files" from "no file defines
319
489
  # the key" — same empty matches[], different fix.
320
490
  "files_searched": len(targets),
491
+ # Keeps `count: 0` beside a non-zero files_searched from reading
492
+ # as "searched them, found nothing". Counted in the loop, not as
493
+ # len(warnings): those also carry resolver-level entries.
494
+ "files_unreadable": files_unreadable,
321
495
  }
322
496
  if warnings:
323
497
  result["warnings"] = warnings
@@ -7,6 +7,8 @@ import logging
7
7
  import threading
8
8
  import time
9
9
  from collections import deque
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
10
12
  from dataclasses import asdict, dataclass
11
13
  from datetime import UTC, datetime
12
14
  from pathlib import Path
@@ -94,6 +96,30 @@ def get_startup_logs() -> list[dict[str, Any]]:
94
96
  return _startup_collector.get_logs()
95
97
 
96
98
 
99
+ @contextmanager
100
+ def preserve_startup_collector() -> Iterator[None]:
101
+ """Keep the startup collector attached across a logging reconfiguration.
102
+
103
+ ``logging.basicConfig(force=True)`` removes *and closes* every handler on
104
+ the root logger. The collector is attached there at import time and is the
105
+ source of ``ha_report_issue``'s startup diagnostics, so it is detached for
106
+ the duration of the block — leaving nothing for the sweep to close — and
107
+ re-attached afterwards.
108
+ """
109
+ collector = _startup_collector
110
+ root = logging.getLogger()
111
+ if collector is None or collector not in root.handlers:
112
+ yield
113
+ return
114
+
115
+ root.removeHandler(collector)
116
+ try:
117
+ yield
118
+ finally:
119
+ if collector not in root.handlers:
120
+ root.addHandler(collector)
121
+
122
+
97
123
  # Parameter keys whose values are masked before a tool call is recorded to the
98
124
  # in-memory ring buffer or persisted to disk. Tool kwargs can carry secrets
99
125
  # (alarm/lock ``code``, tokens, passwords); these must never reach the usage
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.14.2.dev2128
3
+ Version: 7.14.2.dev2131
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