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.
- {ha_mcp_dev-7.13.0.dev1975/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.13.0.dev1980}/PKG-INFO +2 -1
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/README.md +1 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/pyproject.toml +2 -1
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/__main__.py +220 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/config.py +1 -1
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/__init__.py +6 -2
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/capture.py +158 -86
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/provision.py +62 -7
- ha_mcp_dev-7.13.0.dev1980/src/ha_mcp/dashboard_screenshot/theme_guard.py +228 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_dashboards.py +25 -9
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_dashboard_screenshot.py +29 -15
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -1
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {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
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/LICENSE +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/setup.cfg +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/llm_exposure.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/read_only.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_i18n.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_persistence.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_theme.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/locales/README.md +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/locales/en.json +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/settings_ui/settings.js +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/strict_bps.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_api.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_config_reads.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_devices.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_registries.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {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
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_dev.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/model.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/persistence.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/visibility/resolver.py +0 -0
- {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
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/tests/test_constants.py +0 -0
- {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.
|
|
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.
|
|
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 (
|
|
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(
|
{ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/__init__.py
RENAMED
|
@@ -19,7 +19,8 @@ from .capture import (
|
|
|
19
19
|
DashboardImageCapture,
|
|
20
20
|
capture_dashboard_images,
|
|
21
21
|
)
|
|
22
|
-
from .provision import
|
|
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
|
-
"
|
|
35
|
+
"resolve_engine",
|
|
32
36
|
]
|
{ha_mcp_dev-7.13.0.dev1975 → ha_mcp_dev-7.13.0.dev1980}/src/ha_mcp/dashboard_screenshot/capture.py
RENAMED
|
@@ -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
|
|
6
|
-
ever
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
)
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|