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