ha-mcp-dev 7.13.0.dev1975__tar.gz → 7.13.0.dev1980__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 (186) hide show
  1. {ha_mcp_dev-7.13.0.dev1975/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.13.0.dev1980}/PKG-INFO +2 -1
  2. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/README.md +1 -0
  3. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/pyproject.toml +2 -1
  4. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/__main__.py +220 -0
  5. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/config.py +1 -1
  6. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/__init__.py +6 -2
  7. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/capture.py +158 -86
  8. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/provision.py +62 -7
  9. ha_mcp_dev-7.13.0.dev1980/src/ha_mcp/dashboard_screenshot/theme_guard.py +228 -0
  10. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_dashboards.py +25 -9
  11. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_dashboard_screenshot.py +29 -15
  12. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -1
  13. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  14. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/entry_points.txt +1 -0
  15. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/LICENSE +0 -0
  16. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/MANIFEST.in +0 -0
  17. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/setup.cfg +0 -0
  18. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/__init__.py +0 -0
  19. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/_pypi_marker +0 -0
  20. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/_version.py +0 -0
  21. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/auth/__init__.py +0 -0
  22. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/auth/consent_form.py +0 -0
  23. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/auth/provider.py +0 -0
  24. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/backup_manager.py +0 -0
  25. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/browser_landing.py +0 -0
  26. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/__init__.py +0 -0
  27. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/rest_client.py +0 -0
  28. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/supervisor_client.py +0 -0
  29. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/websocket_client.py +0 -0
  30. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/websocket_listener.py +0 -0
  31. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  32. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  33. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/errors.py +0 -0
  34. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/llm_exposure.py +0 -0
  35. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/__init__.py +0 -0
  36. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/approval_queue.py +0 -0
  37. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/evaluator.py +0 -0
  38. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/handlers.py +0 -0
  39. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/middleware.py +0 -0
  40. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/model.py +0 -0
  41. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/persistence.py +0 -0
  42. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/value_sources.py +0 -0
  43. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/py.typed +0 -0
  44. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/read_only.py +0 -0
  45. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  46. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  47. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  48. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  49. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  50. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  51. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  52. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  53. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  54. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  55. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  56. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  57. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
  58. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  59. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
  60. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  61. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  62. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  63. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  64. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  65. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  66. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  67. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
  68. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  69. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  70. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/server.py +0 -0
  71. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/__init__.py +0 -0
  72. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  73. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
  74. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  75. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  76. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  77. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
  78. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  79. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_persistence.py +0 -0
  80. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  81. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_theme.py +0 -0
  82. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  83. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  84. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/locales/en.json +0 -0
  85. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
  86. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/settings.css +0 -0
  87. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/settings.html +0 -0
  88. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/settings.js +0 -0
  89. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/smoke_test.py +0 -0
  90. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  91. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/strict_bps.py +0 -0
  92. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/__init__.py +0 -0
  93. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/auto_backup.py +0 -0
  94. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/backup.py +0 -0
  95. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  96. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_api.py +0 -0
  97. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_config_reads.py +0 -0
  98. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_devices.py +0 -0
  99. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_registries.py +0 -0
  100. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
  101. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  102. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/device_control.py +0 -0
  103. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/enhanced.py +0 -0
  104. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/helpers.py +0 -0
  105. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/__init__.py +0 -0
  106. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/base.py +0 -0
  107. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/matter.py +0 -0
  108. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/thread.py +0 -0
  109. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  110. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/zwave.py +0 -0
  111. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/reference_validator.py +0 -0
  112. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/registry.py +0 -0
  113. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  114. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  115. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  116. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  117. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  118. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  119. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  120. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  121. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  122. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  123. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_addons.py +0 -0
  124. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_areas.py +0 -0
  125. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  126. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  127. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_calendar.py +0 -0
  128. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_camera.py +0 -0
  129. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_categories.py +0 -0
  130. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_code.py +0 -0
  131. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  132. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  133. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  134. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  135. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_dev.py +0 -0
  136. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_energy.py +0 -0
  137. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_entities.py +0 -0
  138. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  139. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_groups.py +0 -0
  140. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_hacs.py +0 -0
  141. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_history.py +0 -0
  142. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_integrations.py +0 -0
  143. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_labels.py +0 -0
  144. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_radio.py +0 -0
  145. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_registry.py +0 -0
  146. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_resources.py +0 -0
  147. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_search.py +0 -0
  148. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_service.py +0 -0
  149. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_services.py +0 -0
  150. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_system.py +0 -0
  151. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_themes.py +0 -0
  152. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_todo.py +0 -0
  153. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_traces.py +0 -0
  154. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_updates.py +0 -0
  155. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_utility.py +0 -0
  156. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  157. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  158. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
  159. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_zones.py +0 -0
  160. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/util_helpers.py +0 -0
  161. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/validation_middleware.py +0 -0
  162. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transforms/__init__.py +0 -0
  163. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transforms/categorized_search.py +0 -0
  164. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  165. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transport_security.py +0 -0
  166. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/update_check.py +0 -0
  167. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/__init__.py +0 -0
  168. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/config_hash.py +0 -0
  169. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/data_paths.py +0 -0
  170. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/domain_handlers.py +0 -0
  171. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  172. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  173. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/operation_manager.py +0 -0
  174. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/python_sandbox.py +0 -0
  175. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/skill_loader.py +0 -0
  176. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/usage_logger.py +0 -0
  177. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/__init__.py +0 -0
  178. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/model.py +0 -0
  179. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/persistence.py +0 -0
  180. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/resolver.py +0 -0
  181. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  182. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  183. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  184. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/tests/__init__.py +0 -0
  185. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/tests/test_constants.py +0 -0
  186. {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/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.13.0.dev1975
3
+ Version: 7.13.0.dev1980
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
@@ -118,6 +118,7 @@ These run the server outside Home Assistant — useful for **Container** / **Cor
118
118
  - **Docker (HTTP server):** run `ghcr.io/homeassistant-ai/ha-mcp` in HTTP mode, pointed at your Home Assistant URL and a long-lived token, and connect your client to its secret URL. See the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/) for the full command and per-client config.
119
119
  - **PyPI / uvx (HTTP server):** run the published `ha-mcp` package with `uvx ha-mcp@latest` (or pip) as a streamable-HTTP server the same way. Details in the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/).
120
120
  - **Local stdio (not recommended):** runs ha-mcp on your own machine over stdio. The one-command installers in the **Demo server** section below use this path; the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/) covers connecting it to your own Home Assistant.
121
+ - **OIDC authentication:** gate remote access behind an external identity provider (Authentik, Keycloak, Auth0, Google, etc.) instead of a secret URL — all authenticated users share the server's Home Assistant credentials. See [OIDC Mode](docs/oidc.md).
121
122
 
122
123
  > ⚠️ **stdio has known transport issues.** The stdio transport has connection problems that streamable HTTP does not ([#1713](https://github.com/homeassistant-ai/ha-mcp/issues/1713)). It is recommended only for demo/testing tinkering — for a real setup, use the custom component or an HTTP method above.
123
124
 
@@ -85,6 +85,7 @@ These run the server outside Home Assistant — useful for **Container** / **Cor
85
85
  - **Docker (HTTP server):** run `ghcr.io/homeassistant-ai/ha-mcp` in HTTP mode, pointed at your Home Assistant URL and a long-lived token, and connect your client to its secret URL. See the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/) for the full command and per-client config.
86
86
  - **PyPI / uvx (HTTP server):** run the published `ha-mcp` package with `uvx ha-mcp@latest` (or pip) as a streamable-HTTP server the same way. Details in the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/).
87
87
  - **Local stdio (not recommended):** runs ha-mcp on your own machine over stdio. The one-command installers in the **Demo server** section below use this path; the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/) covers connecting it to your own Home Assistant.
88
+ - **OIDC authentication:** gate remote access behind an external identity provider (Authentik, Keycloak, Auth0, Google, etc.) instead of a secret URL — all authenticated users share the server's Home Assistant credentials. See [OIDC Mode](docs/oidc.md).
88
89
 
89
90
  > ⚠️ **stdio has known transport issues.** The stdio transport has connection problems that streamable HTTP does not ([#1713](https://github.com/homeassistant-ai/ha-mcp/issues/1713)). It is recommended only for demo/testing tinkering — for a real setup, use the custom component or an HTTP method above.
90
91
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.13.0.dev1975"
7
+ version = "7.13.0.dev1980"
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
@@ -59,6 +59,7 @@ hamcp-test-env = "tests.test_env_manager:main"
59
59
  ha-mcp-web = "ha_mcp.__main__:main_web"
60
60
  ha-mcp-sse = "ha_mcp.__main__:main_sse"
61
61
  ha-mcp-oauth = "ha_mcp.__main__:main_oauth"
62
+ ha-mcp-oidc = "ha_mcp.__main__:main_oidc"
62
63
 
63
64
  [tool.setuptools]
64
65
  package-dir = {"" = "src", "tests" = "tests"}
@@ -979,6 +979,39 @@ def _healthz_enabled() -> bool:
979
979
  return os.getenv("MCP_HEALTHZ", "").strip().lower() in ("1", "true", "yes", "on")
980
980
 
981
981
 
982
+ def _oidc_verify_id_token_enabled() -> bool:
983
+ """True when OIDC_VERIFY_ID_TOKEN opts in to ID-token verification.
984
+
985
+ Off by default: FastMCP's OIDCProxy verifies the access token as a JWT,
986
+ which works for providers like Authentik and Keycloak. Providers that
987
+ issue opaque access tokens (Google always; Auth0 without an API
988
+ audience) need ``verify_id_token=True`` so FastMCP verifies the ID token
989
+ instead.
990
+ """
991
+ return os.getenv("OIDC_VERIFY_ID_TOKEN", "").strip().lower() in (
992
+ "1",
993
+ "true",
994
+ "yes",
995
+ "on",
996
+ )
997
+
998
+
999
+ def _oidc_allowed_client_redirect_uris() -> list[str] | None:
1000
+ """Parse OIDC_ALLOWED_CLIENT_REDIRECT_URIS into a list, or None if unset.
1001
+
1002
+ Comma-separated list of redirect URI patterns FastMCP's OIDCProxy will
1003
+ accept from dynamically-registered clients. With no allow-list set,
1004
+ FastMCP still validates a client's redirect URI against that same
1005
+ client's own registered redirect URIs — the exposure is that open DCR
1006
+ lets an attacker register their own client with their own redirect URI;
1007
+ internet-facing deployments should set this to constrain what any
1008
+ dynamically-registered client may register in the first place.
1009
+ """
1010
+ raw = os.getenv("OIDC_ALLOWED_CLIENT_REDIRECT_URIS", "")
1011
+ uris = [uri.strip() for uri in raw.split(",") if uri.strip()]
1012
+ return uris or None
1013
+
1014
+
982
1015
  def register_browser_landing(
983
1016
  mcp_instance: "FastMCP | _DeferredMCP",
984
1017
  path: str,
@@ -1225,5 +1258,192 @@ async def _run_oauth_server(
1225
1258
  )
1226
1259
 
1227
1260
 
1261
+ def main_oidc() -> None:
1262
+ """Run server with OIDC authentication over HTTP.
1263
+
1264
+ This mode enables authentication via an external OIDC provider
1265
+ (Authentik, Keycloak, Auth0, Google, etc.). All authenticated users
1266
+ share the same Home Assistant instance via the configured credentials.
1267
+
1268
+ Unlike OAuth mode which collects per-user HA credentials via a consent form,
1269
+ OIDC mode is purely an access gate — users authenticate through the OIDC provider,
1270
+ then all requests use the shared HA credentials from environment variables.
1271
+
1272
+ Environment:
1273
+ - OIDC_CONFIG_URL (required): OIDC discovery URL (.well-known/openid-configuration)
1274
+ - OIDC_CLIENT_ID (required): OAuth client ID registered with your OIDC provider
1275
+ - OIDC_CLIENT_SECRET (required): OAuth client secret from your OIDC provider
1276
+ - MCP_BASE_URL (required): Public HTTPS URL where this server is accessible
1277
+ - HOMEASSISTANT_URL (required): Home Assistant instance URL
1278
+ - HOMEASSISTANT_TOKEN (required): Home Assistant long-lived access token or supervisor token
1279
+ - OIDC_JWT_SIGNING_KEY (optional): Secret key for signing FastMCP JWTs. Sessions
1280
+ persist across restarts by default (the key is derived from OIDC_CLIENT_SECRET);
1281
+ set this to decouple the signing key from the client secret. Generate with:
1282
+ python -c "import secrets; print(secrets.token_urlsafe(32))"
1283
+ - OIDC_ALLOWED_CLIENT_REDIRECT_URIS (optional): Comma-separated list of redirect URI
1284
+ patterns accepted from dynamically-registered clients. Strongly recommended for
1285
+ internet-facing deployments.
1286
+ - OIDC_VERIFY_ID_TOKEN (optional, default: false): Set true for providers that issue
1287
+ opaque access tokens (e.g. Google, or Auth0 without an API audience).
1288
+ - OIDC_AUDIENCE (optional): Expected `aud` claim for IdP-issued access tokens.
1289
+ Without it (and with OIDC_VERIFY_ID_TOKEN off), FastMCP's JWT verifier checks
1290
+ issuer, signature, and expiry but not audience. With OIDC_VERIFY_ID_TOKEN=true,
1291
+ verification pins `aud` to OIDC_CLIENT_ID instead and this value is only
1292
+ forwarded to the IdP's authorize/token endpoints (it is what makes Auth0
1293
+ issue JWT rather than opaque access tokens).
1294
+ - MCP_HOST (optional, default: "0.0.0.0"; set 127.0.0.1 to restrict to loopback
1295
+ behind a same-host reverse proxy)
1296
+ - MCP_PORT (optional, default: 8086)
1297
+ - MCP_SECRET_PATH (optional, default: "/mcp")
1298
+ - LOG_LEVEL (optional, default: INFO)
1299
+ """
1300
+ # Configure logging for OIDC mode (force=True needed since logging may already be configured)
1301
+ log_level = os.getenv("LOG_LEVEL", "INFO").upper()
1302
+ _setup_logging(log_level, force=True)
1303
+ for logger_name in ["ha_mcp", "ha_mcp.auth", "ha_mcp.auth.provider"]:
1304
+ logging.getLogger(logger_name).setLevel(getattr(logging, log_level))
1305
+ logger.info(f"OIDC mode logging configured at {log_level} level")
1306
+ _log_startup_version()
1307
+
1308
+ # Validate HA credentials (OIDC mode needs them — unlike OAuth mode)
1309
+ from ha_mcp.config import get_settings
1310
+
1311
+ settings = get_settings()
1312
+ _validate_standard_credentials(settings)
1313
+
1314
+ # Validate OIDC-specific env vars
1315
+ oidc_config_url = os.getenv("OIDC_CONFIG_URL")
1316
+ oidc_client_id = os.getenv("OIDC_CLIENT_ID")
1317
+ oidc_client_secret = os.getenv("OIDC_CLIENT_SECRET")
1318
+ base_url = os.getenv("MCP_BASE_URL")
1319
+
1320
+ missing = []
1321
+ if not oidc_config_url:
1322
+ missing.append("OIDC_CONFIG_URL")
1323
+ if not oidc_client_id:
1324
+ missing.append("OIDC_CLIENT_ID")
1325
+ if not oidc_client_secret:
1326
+ missing.append("OIDC_CLIENT_SECRET")
1327
+ if not base_url:
1328
+ missing.append("MCP_BASE_URL")
1329
+
1330
+ if missing:
1331
+ logger.error(
1332
+ f"Missing required environment variables for OIDC mode: {', '.join(missing)}"
1333
+ )
1334
+ logger.error(
1335
+ "OIDC mode requires an external OIDC provider (Authentik, Keycloak, Auth0, etc.)"
1336
+ )
1337
+ sys.exit(1)
1338
+
1339
+ assert oidc_config_url is not None
1340
+ assert oidc_client_id is not None
1341
+ assert oidc_client_secret is not None
1342
+ assert base_url is not None
1343
+
1344
+ host, port, path = _get_http_runtime(default_port=8086)
1345
+
1346
+ _run_entrypoint(
1347
+ _run_oidc_server(
1348
+ config_url=oidc_config_url,
1349
+ client_id=oidc_client_id,
1350
+ client_secret=oidc_client_secret,
1351
+ base_url=base_url,
1352
+ host=host,
1353
+ port=port,
1354
+ path=path,
1355
+ ),
1356
+ "OIDC server",
1357
+ )
1358
+
1359
+
1360
+ async def _run_oidc_server(
1361
+ config_url: str,
1362
+ client_id: str,
1363
+ client_secret: str,
1364
+ base_url: str,
1365
+ host: str,
1366
+ port: int,
1367
+ path: str,
1368
+ ) -> None:
1369
+ """Run the OIDC-authenticated MCP server.
1370
+
1371
+ Unlike OAuth mode which uses OAuthProxyClient for per-user credential routing,
1372
+ OIDC mode uses the standard HomeAssistantClient with shared credentials.
1373
+ OIDC is purely an access gate — all authenticated users share the same HA instance.
1374
+
1375
+ Args:
1376
+ config_url: OIDC discovery URL (.well-known/openid-configuration)
1377
+ client_id: OAuth client ID from the OIDC provider
1378
+ client_secret: OAuth client secret from the OIDC provider
1379
+ base_url: Public HTTPS URL where this server is accessible
1380
+ host: Bind host (typically 0.0.0.0; override via MCP_HOST)
1381
+ port: Port to listen on
1382
+ path: MCP endpoint path
1383
+ """
1384
+ from fastmcp.server.auth.oidc_proxy import OIDCProxy
1385
+
1386
+ from ha_mcp.server import HomeAssistantSmartMCPServer
1387
+ from ha_mcp.transport_security import ensure_host_origin_guard_default_off
1388
+
1389
+ # OIDC mode is deployed behind a public reverse proxy or LAN hostname;
1390
+ # disable FastMCP's Host/Origin rebinding guard so proxied requests are
1391
+ # not rejected with 421/403 before OIDC auth runs.
1392
+ ensure_host_origin_guard_default_off()
1393
+
1394
+ proxy_kwargs: dict[str, Any] = {
1395
+ "config_url": config_url,
1396
+ "client_id": client_id,
1397
+ "client_secret": client_secret,
1398
+ "base_url": base_url,
1399
+ # "external" tells FastMCP that consent is handled by the upstream
1400
+ # IdP (Authentik, Keycloak, etc.) -- unlike `False`, this does not
1401
+ # log a security warning at startup that consent is disabled.
1402
+ "require_authorization_consent": "external",
1403
+ # Preserve `or None`: an empty-but-set env var must not bypass
1404
+ # FastMCP's derive-from-client-secret default for jwt_signing_key.
1405
+ "jwt_signing_key": os.getenv("OIDC_JWT_SIGNING_KEY") or None,
1406
+ }
1407
+
1408
+ allowed_redirect_uris = _oidc_allowed_client_redirect_uris()
1409
+ if allowed_redirect_uris:
1410
+ proxy_kwargs["allowed_client_redirect_uris"] = allowed_redirect_uris
1411
+ else:
1412
+ logger.warning(
1413
+ "OIDC_ALLOWED_CLIENT_REDIRECT_URIS is not set: any dynamically-registered "
1414
+ "(DCR) client's own redirect URIs are accepted. Internet-facing "
1415
+ "deployments should set it."
1416
+ )
1417
+
1418
+ if _oidc_verify_id_token_enabled():
1419
+ proxy_kwargs["verify_id_token"] = True
1420
+
1421
+ audience = os.getenv("OIDC_AUDIENCE", "").strip()
1422
+ if audience:
1423
+ proxy_kwargs["audience"] = audience
1424
+
1425
+ # Create OIDC auth provider — auto-discovers endpoints from config_url
1426
+ auth = OIDCProxy(**proxy_kwargs)
1427
+
1428
+ # Standard server with shared credentials (no proxy client needed)
1429
+ global _server
1430
+ _server = HomeAssistantSmartMCPServer()
1431
+ mcp_instance = _server.mcp
1432
+ mcp_instance.auth = auth
1433
+
1434
+ logger.info("Server created with OIDC authentication")
1435
+ # Unlike standard/OAuth HTTP modes, OIDC mode intentionally registers no
1436
+ # browser landing page and no settings-UI sidecar: an unauthenticated
1437
+ # browser GET gets a bare 405 rather than a friendly page, and there is
1438
+ # no settings UI surface here.
1439
+ if _healthz_enabled():
1440
+ _register_healthz_route(mcp_instance)
1441
+ logger.info(f"Starting OIDC-enabled MCP server at {base_url}{path}")
1442
+
1443
+ await _run_with_shutdown(
1444
+ mcp_instance.run_async(**_http_run_kwargs("http", host, port, path))
1445
+ )
1446
+
1447
+
1228
1448
  if __name__ == "__main__":
1229
1449
  main()
@@ -836,7 +836,7 @@ ADVANCED_SETTINGS_FIELDS: tuple[AdvancedField, ...] = (
836
836
  AdvancedField("enable_websocket", "ENABLE_WEBSOCKET", bool, "operations", True),
837
837
  # Dashboard-screenshot engine URL (#1538): docker/.env users could set
838
838
  # HAMCP_DASHBOARD_SCREENSHOT_ENGINE_URL, but add-on users had no path to
839
- # it. It is resolved live per capture (resolve_engine_url), so unlike the
839
+ # it. It is resolved live per capture (resolve_engine), so unlike the
840
840
  # time budgets it takes effect without a restart. Blank = auto-discover
841
841
  # the Puppet add-on via the Supervisor.
842
842
  AdvancedField(
@@ -19,7 +19,8 @@ from .capture import (
19
19
  DashboardImageCapture,
20
20
  capture_dashboard_images,
21
21
  )
22
- from .provision import resolve_engine_url
22
+ from .provision import EngineTarget, resolve_engine
23
+ from .theme_guard import EngineCredential, ThemeGuard
23
24
 
24
25
  __all__ = [
25
26
  "DEFAULT_HEIGHT",
@@ -27,6 +28,9 @@ __all__ = [
27
28
  "DEFAULT_WAIT_MS",
28
29
  "DEFAULT_WIDTH",
29
30
  "DashboardImageCapture",
31
+ "EngineCredential",
32
+ "EngineTarget",
33
+ "ThemeGuard",
30
34
  "capture_dashboard_images",
31
- "resolve_engine_url",
35
+ "resolve_engine",
32
36
  ]
@@ -2,8 +2,11 @@
2
2
 
3
3
  The engine (balloob's Puppet add-on, or a docker-compose sidecar)
4
4
  authenticates to Home Assistant with its OWN configured long-lived token, so
5
- this client passes only the dashboard path + render parameters — no HA token
6
- ever flows through ha-mcp or the LLM for screenshots.
5
+ this client passes only the dashboard path + render parameters to the engine
6
+ — no HA token is ever sent to the engine or exposed to the LLM. The
7
+ :mod:`theme_guard` bracket around each capture batch may hold the engine's
8
+ token in server memory to restore the engine user's saved theme (issue
9
+ #1909); see that module's docstring for the containment rules.
7
10
  """
8
11
 
9
12
  from __future__ import annotations
@@ -20,7 +23,8 @@ from fastmcp.exceptions import ToolError
20
23
 
21
24
  from ..errors import ErrorCode, create_error_response
22
25
  from ..tools.helpers import raise_tool_error
23
- from .provision import TOKEN_HINT, resolve_engine_url
26
+ from .provision import TOKEN_HINT, resolve_engine
27
+ from .theme_guard import ThemeGuard
24
28
 
25
29
  logger = logging.getLogger(__name__)
26
30
 
@@ -757,9 +761,30 @@ async def _request_or_collect_failure(
757
761
  return None
758
762
 
759
763
 
764
+ def _viewport_params(
765
+ options: _CaptureOptions, viewport: _ViewportRequest
766
+ ) -> dict[str, str]:
767
+ """Build one viewport's engine query parameters."""
768
+ params: dict[str, str] = {
769
+ "viewport": f"{viewport.width}x{viewport.height}",
770
+ "zoom": str(options.zoom),
771
+ "wait": str(options.wait_ms),
772
+ "format": options.image_format,
773
+ }
774
+ if options.theme is not None:
775
+ params["theme"] = options.theme
776
+ if options.dark_mode:
777
+ # Puppet checks only for query-key presence.
778
+ params["dark"] = ""
779
+ if options.language is not None:
780
+ params["lang"] = options.language
781
+ return params
782
+
783
+
760
784
  def _complete_capture_batch(
761
785
  captures: list[DashboardImageCapture],
762
786
  partial_failures: list[dict[str, Any]] | None,
787
+ guard_warnings: list[str],
763
788
  ) -> list[DashboardImageCapture]:
764
789
  """Return partial successes, but raise when every requested capture failed."""
765
790
  if not captures and partial_failures:
@@ -769,10 +794,41 @@ def _complete_capture_batch(
769
794
  "failure_count": len(partial_failures),
770
795
  "screenshot_failures": partial_failures,
771
796
  }
797
+ if guard_warnings:
798
+ aggregate_failure["warnings"] = [
799
+ *aggregate_failure.get("warnings", []),
800
+ *guard_warnings,
801
+ ]
772
802
  raise_tool_error(aggregate_failure)
773
803
  return captures
774
804
 
775
805
 
806
+ def _reraise_with_guard_warnings(error: ToolError, warnings: list[str]) -> NoReturn:
807
+ """Re-raise a capture ToolError with the theme guard's warnings attached.
808
+
809
+ A batch that raises may already have rendered (and clobbered the engine
810
+ user's theme) before failing, so a failed restore must stay visible on
811
+ the error path too — otherwise the tool contract ("an attempted but
812
+ failed restore surfaces as a warning") silently breaks exactly when the
813
+ theme is most likely left flipped.
814
+ """
815
+ if not warnings:
816
+ raise error
817
+ try:
818
+ payload = json.loads(str(error))
819
+ except (json.JSONDecodeError, TypeError):
820
+ payload = None
821
+ if not isinstance(payload, dict):
822
+ raise error
823
+ existing = payload.get("warnings")
824
+ payload["warnings"] = [
825
+ *(existing if isinstance(existing, list) else []),
826
+ *warnings,
827
+ ]
828
+ raise_tool_error(payload)
829
+ raise AssertionError("unreachable: raise_tool_error always raises")
830
+
831
+
776
832
  async def capture_dashboard_images(
777
833
  dashboard_path: str,
778
834
  *,
@@ -789,6 +845,8 @@ async def capture_dashboard_images(
789
845
  image_format: ScreenshotFormat = "png",
790
846
  render_timeout_seconds: float = DEFAULT_RENDER_TIMEOUT_SECONDS,
791
847
  partial_failures: list[dict[str, Any]] | None = None,
848
+ client: Any | None = None,
849
+ capture_warnings: list[str] | None = None,
792
850
  ) -> list[DashboardImageCapture]:
793
851
  """Render one or more ordered dashboard images via the screenshot engine.
794
852
 
@@ -796,6 +854,12 @@ async def capture_dashboard_images(
796
854
  preset or custom dimensions when needed; omitting it preserves each
797
855
  preset's native orientation. ``full_page`` is a compatibility alias for
798
856
  requesting the engine's native ``WIDTHxauto`` viewport.
857
+
858
+ The batch is bracketed by a :class:`ThemeGuard` that restores the engine
859
+ user's saved frontend theme if rendering changed it (issue #1909).
860
+ ``client`` supplies the guard's non-add-on credential fallback and
861
+ ``capture_warnings`` collects the guard's non-fatal warnings; both are
862
+ optional and never affect the captures themselves.
799
863
  """
800
864
  path = _validate_dashboard_path(dashboard_path)
801
865
  options = validate_capture_parameters(
@@ -814,94 +878,102 @@ async def capture_dashboard_images(
814
878
  )
815
879
  viewports = _capture_viewports(options)
816
880
 
817
- engine = await resolve_engine_url()
881
+ engine_target = await resolve_engine()
882
+ engine = engine_target.url
818
883
  url = f"{engine}/{path}"
819
884
  mime_type = _MIME_TYPES[options.image_format]
820
885
  captures: list[DashboardImageCapture] = []
821
886
 
822
- async with httpx.AsyncClient(
823
- timeout=httpx.Timeout(options.render_timeout_seconds)
824
- ) as http_client:
825
- for capture_index, viewport in enumerate(viewports):
826
- params: dict[str, str] = {
827
- "viewport": f"{viewport.width}x{viewport.height}",
828
- "zoom": str(options.zoom),
829
- "wait": str(options.wait_ms),
830
- "format": options.image_format,
831
- }
832
- if options.theme is not None:
833
- params["theme"] = options.theme
834
- if options.dark_mode:
835
- # Puppet checks only for query-key presence.
836
- params["dark"] = ""
837
- if options.language is not None:
838
- params["lang"] = options.language
839
-
840
- request_context = {
841
- "path": path,
842
- "preset": viewport.preset,
843
- "width": viewport.width,
844
- "height": viewport.height,
845
- "requested_format": options.image_format,
846
- "capture_index": capture_index,
847
- "capture_count": len(viewports),
848
- "completed_count": len(captures),
849
- }
850
- aggregate_bytes = sum(capture.size_bytes for capture in captures)
851
- remaining_batch_bytes = MAX_BATCH_PAYLOAD_BYTES - aggregate_bytes
852
- if remaining_batch_bytes <= 0:
853
- assert partial_failures is not None
854
- partial_failures.append(
855
- create_error_response(
856
- ErrorCode.IMAGE_PAYLOAD_TOO_LARGE,
857
- "Screenshot image batch reached the server's safe "
858
- "inline-image limit.",
859
- context={
860
- **request_context,
861
- "aggregate_bytes_before_capture": aggregate_bytes,
862
- "limit_kind": "batch",
863
- "limit_bytes": MAX_BATCH_PAYLOAD_BYTES,
864
- },
887
+ # The engine dispatches a theme write into the authenticated frontend on
888
+ # cold renders, which Home Assistant persists to the engine user's real
889
+ # profile (issue #1909). Snapshot before, restore after — including when
890
+ # the batch fails, since the engine may already have rendered (and
891
+ # written) before the failure.
892
+ guard = ThemeGuard.for_capture(engine_target.addon_credential, client)
893
+ await guard.take_snapshot()
894
+ batch_error: ToolError | None = None
895
+ try:
896
+ async with httpx.AsyncClient(
897
+ timeout=httpx.Timeout(options.render_timeout_seconds)
898
+ ) as http_client:
899
+ for capture_index, viewport in enumerate(viewports):
900
+ params = _viewport_params(options, viewport)
901
+
902
+ request_context = {
903
+ "path": path,
904
+ "preset": viewport.preset,
905
+ "width": viewport.width,
906
+ "height": viewport.height,
907
+ "requested_format": options.image_format,
908
+ "capture_index": capture_index,
909
+ "capture_count": len(viewports),
910
+ "completed_count": len(captures),
911
+ }
912
+ aggregate_bytes = sum(capture.size_bytes for capture in captures)
913
+ remaining_batch_bytes = MAX_BATCH_PAYLOAD_BYTES - aggregate_bytes
914
+ if remaining_batch_bytes <= 0:
915
+ assert partial_failures is not None
916
+ partial_failures.append(
917
+ create_error_response(
918
+ ErrorCode.IMAGE_PAYLOAD_TOO_LARGE,
919
+ "Screenshot image batch reached the server's safe "
920
+ "inline-image limit.",
921
+ context={
922
+ **request_context,
923
+ "aggregate_bytes_before_capture": aggregate_bytes,
924
+ "limit_kind": "batch",
925
+ "limit_bytes": MAX_BATCH_PAYLOAD_BYTES,
926
+ },
927
+ )
865
928
  )
866
- )
867
- break
868
-
869
- outcome = await _request_or_collect_failure(
870
- http_client,
871
- url=url,
872
- path=path,
873
- engine=engine,
874
- params=params,
875
- options=options,
876
- viewport=viewport,
877
- mime_type=mime_type,
878
- request_context=request_context,
879
- aggregate_bytes=aggregate_bytes,
880
- remaining_batch_bytes=remaining_batch_bytes,
881
- partial_failures=partial_failures,
882
- )
883
- if outcome is None:
884
- if (
885
- partial_failures
886
- and partial_failures[-1].get("limit_kind") == "batch"
887
- ):
888
929
  break
889
- continue
890
- image_data, capture_height, fallback_used = outcome
891
-
892
- captures.append(
893
- DashboardImageCapture(
894
- data=image_data,
895
- width=viewport.width,
896
- height=capture_height,
897
- preset=viewport.preset,
898
- orientation=viewport.orientation,
899
- image_format=options.image_format,
900
- mime_type=mime_type,
901
- size_bytes=len(image_data),
930
+
931
+ outcome = await _request_or_collect_failure(
932
+ http_client,
933
+ url=url,
934
+ path=path,
935
+ engine=engine,
936
+ params=params,
902
937
  options=options,
903
- legacy_full_page_fallback=fallback_used,
938
+ viewport=viewport,
939
+ mime_type=mime_type,
940
+ request_context=request_context,
941
+ aggregate_bytes=aggregate_bytes,
942
+ remaining_batch_bytes=remaining_batch_bytes,
943
+ partial_failures=partial_failures,
904
944
  )
905
- )
906
-
907
- return _complete_capture_batch(captures, partial_failures)
945
+ if outcome is None:
946
+ if (
947
+ partial_failures
948
+ and partial_failures[-1].get("limit_kind") == "batch"
949
+ ):
950
+ break
951
+ continue
952
+ image_data, capture_height, fallback_used = outcome
953
+
954
+ captures.append(
955
+ DashboardImageCapture(
956
+ data=image_data,
957
+ width=viewport.width,
958
+ height=capture_height,
959
+ preset=viewport.preset,
960
+ orientation=viewport.orientation,
961
+ image_format=options.image_format,
962
+ mime_type=mime_type,
963
+ size_bytes=len(image_data),
964
+ options=options,
965
+ legacy_full_page_fallback=fallback_used,
966
+ )
967
+ )
968
+ except ToolError as exc:
969
+ # Held (not re-raised here) so the restore in ``finally`` runs first
970
+ # and its outcome can be attached to the error payload below.
971
+ batch_error = exc
972
+ finally:
973
+ await guard.restore()
974
+ if capture_warnings is not None:
975
+ capture_warnings.extend(guard.warnings)
976
+
977
+ if batch_error is not None:
978
+ _reraise_with_guard_warnings(batch_error, guard.warnings)
979
+ return _complete_capture_batch(captures, partial_failures, guard.warnings)