ha-mcp-dev 7.7.0.dev681__tar.gz → 7.7.0.dev683__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.7.0.dev681/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.7.0.dev683}/PKG-INFO +2 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/README.md +1 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/pyproject.toml +1 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/config.py +13 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/errors.py +4 -0
- ha_mcp_dev-7.7.0.dev683/src/ha_mcp/read_only.py +431 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/server.py +68 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/settings.css +6 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/settings.js +138 -21
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/settings_ui.py +36 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/smoke_test.py +1 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/registry.py +8 -3
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_deep.py +1 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_config_helpers.py +12 -12
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_integrations.py +1 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_search.py +23 -4
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/operation_manager.py +3 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp_dev.egg-info/SOURCES.txt +2 -1
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/tests/test_env_manager.py +4 -3
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/LICENSE +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/setup.cfg +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- /ha_mcp_dev-7.7.0.dev681/src/ha_mcp/tools/tools_config_entry_flow.py → /ha_mcp_dev-7.7.0.dev683/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.7.0.dev681 → ha_mcp_dev-7.7.0.dev683}/tests/test_constants.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ha-mcp-dev
|
|
3
|
-
Version: 7.7.0.
|
|
3
|
+
Version: 7.7.0.dev683
|
|
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
|
|
@@ -204,6 +204,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
204
204
|
| **🔧 Manage** | Automations, scripts, helpers, dashboards, areas, zones, groups, calendars, blueprints |
|
|
205
205
|
| **📊 Monitor** | History, statistics, camera snapshots, automation traces, ZHA devices |
|
|
206
206
|
| **💾 System** | Backup/restore, updates, add-ons, device registry |
|
|
207
|
+
| **🔒 Safety** | Read Only Mode toggle, per-tool enable/disable, tool security policies (user approval), automatic edit backups |
|
|
207
208
|
|
|
208
209
|
<details>
|
|
209
210
|
<!-- TOOLS_TABLE_START -->
|
|
@@ -174,6 +174,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
174
174
|
| **🔧 Manage** | Automations, scripts, helpers, dashboards, areas, zones, groups, calendars, blueprints |
|
|
175
175
|
| **📊 Monitor** | History, statistics, camera snapshots, automation traces, ZHA devices |
|
|
176
176
|
| **💾 System** | Backup/restore, updates, add-ons, device registry |
|
|
177
|
+
| **🔒 Safety** | Read Only Mode toggle, per-tool enable/disable, tool security policies (user approval), automatic edit backups |
|
|
177
178
|
|
|
178
179
|
<details>
|
|
179
180
|
<!-- TOOLS_TABLE_START -->
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.7.0.
|
|
7
|
+
version = "7.7.0.dev683"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13,<3.14"
|
|
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
|
|
|
126
126
|
False, alias="ENABLE_TOOL_SECURITY_POLICIES"
|
|
127
127
|
)
|
|
128
128
|
|
|
129
|
+
# Read Only Mode — global safety toggle (discussion #1569). When on,
|
|
130
|
+
# write-capable tools are hidden from the MCP catalog and every write
|
|
131
|
+
# operation is blocked at call time with a structured READ_ONLY_MODE
|
|
132
|
+
# error. Mixed read/write tools whose read surface has no pure-read
|
|
133
|
+
# duplicate stay available with their write actions blocked (see
|
|
134
|
+
# read_only.py:READ_ONLY_EXEMPT_TOOLS). Off by default.
|
|
135
|
+
read_only_mode: bool = Field(False, alias="READ_ONLY_MODE")
|
|
136
|
+
|
|
129
137
|
# Master beta-features toggle. UI-only — intentionally not in any
|
|
130
138
|
# addon config.yaml schema. Consumed by the master gate in
|
|
131
139
|
# ``_apply_feature_flag_overrides``, which force-sets the
|
|
@@ -545,6 +553,11 @@ FEATURE_FLAG_FIELDS: tuple[FeatureFlagField, ...] = (
|
|
|
545
553
|
FeatureFlagField(
|
|
546
554
|
"enable_tool_security_policies", "ENABLE_TOOL_SECURITY_POLICIES", bool
|
|
547
555
|
),
|
|
556
|
+
# Non-beta global safety toggle (discussion #1569). Lives here so the
|
|
557
|
+
# Tools-tab toggle and the Server Settings row share the same
|
|
558
|
+
# /api/settings/features plumbing, override-file persistence, and
|
|
559
|
+
# addon Supervisor routing as every other feature flag.
|
|
560
|
+
FeatureFlagField("read_only_mode", "READ_ONLY_MODE", bool),
|
|
548
561
|
# Non-beta, default-ON master switch for write-tool skill_content
|
|
549
562
|
# delivery (#1182). Grouped with the non-beta flags above the beta
|
|
550
563
|
# run below; intentionally NOT in BETA_FEATURE_FIELDS (it must not be
|
|
@@ -96,6 +96,10 @@ class ErrorCode(StrEnum):
|
|
|
96
96
|
USER_DENIED = "USER_DENIED"
|
|
97
97
|
POLICY_LOAD_FAILED = "POLICY_LOAD_FAILED"
|
|
98
98
|
|
|
99
|
+
# Read Only Mode (discussion #1569). A write operation was blocked
|
|
100
|
+
# because the server-wide Read Only Mode toggle is on.
|
|
101
|
+
READ_ONLY_MODE = "READ_ONLY_MODE"
|
|
102
|
+
|
|
99
103
|
|
|
100
104
|
# Default suggestions for common error codes
|
|
101
105
|
DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Read-only mode — catalog filtering and call-time write blocking (#1569).
|
|
2
|
+
|
|
3
|
+
When ``Settings.read_only_mode`` is on (Tools-tab toggle in the web UI,
|
|
4
|
+
``read_only_mode`` addon option, or ``READ_ONLY_MODE`` env var):
|
|
5
|
+
|
|
6
|
+
- ``ReadOnlyToolsTransform`` hides write-capable tools from the MCP
|
|
7
|
+
catalog at list time, except the exempt mixed read/write tools in
|
|
8
|
+
``READ_ONLY_EXEMPT_TOOLS``.
|
|
9
|
+
- ``ReadOnlyMiddleware`` blocks every write operation at call time with
|
|
10
|
+
a structured ``READ_ONLY_MODE`` error — including the write actions
|
|
11
|
+
of the exempt mixed tools, which stay callable for their read
|
|
12
|
+
operations only.
|
|
13
|
+
|
|
14
|
+
Both consult the live settings singleton per request, so flipping the
|
|
15
|
+
toggle in standalone HTTP mode takes effect without a restart (addon
|
|
16
|
+
and stdio modes pick it up on restart, like every other feature flag).
|
|
17
|
+
|
|
18
|
+
A tool counts as write-capable when its ``readOnlyHint`` annotation is
|
|
19
|
+
not ``True`` — the same fail-closed default the policy handlers and the
|
|
20
|
+
search-proxy categorizer apply to unannotated tools.
|
|
21
|
+
|
|
22
|
+
The exempt tools are the mixed read/write tools whose read surface has
|
|
23
|
+
no pure-read duplicate elsewhere in the catalog — disabling them
|
|
24
|
+
outright would make that data unreachable in read-only mode. Each entry
|
|
25
|
+
carries an argument-level predicate that decides, per invocation,
|
|
26
|
+
whether the call is a read (allowed) or a write (blocked).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
34
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
|
|
35
|
+
|
|
36
|
+
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
|
|
37
|
+
from fastmcp.server.transforms import Transform
|
|
38
|
+
from fastmcp.tools import Tool
|
|
39
|
+
|
|
40
|
+
from .config import get_global_settings
|
|
41
|
+
from .errors import ErrorCode, create_error_response
|
|
42
|
+
from .policy.middleware import PROXY_META_TOOLS
|
|
43
|
+
from .tools.helpers import raise_tool_error
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from fastmcp.server.transforms import GetToolNext
|
|
47
|
+
from fastmcp.utilities.versions import VersionSpec
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReadOnlyExemption(NamedTuple):
|
|
53
|
+
"""One mixed read/write tool that stays enabled in read-only mode.
|
|
54
|
+
|
|
55
|
+
``blocked_write`` inspects the call arguments and returns ``None``
|
|
56
|
+
when the invocation is a read, or a short human-readable description
|
|
57
|
+
of the write operation when it must be blocked. ``allowed`` is a
|
|
58
|
+
one-line summary of what remains available, surfaced in the error so
|
|
59
|
+
the LLM can self-correct.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
blocked_write: Callable[[dict[str, Any]], str | None]
|
|
63
|
+
allowed: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _backup_write(args: dict[str, Any]) -> str | None:
|
|
67
|
+
scope = args.get("scope")
|
|
68
|
+
action = args.get("action")
|
|
69
|
+
if scope == "edits" and action in ("list", "view"):
|
|
70
|
+
return None
|
|
71
|
+
return f"scope={scope!r}, action={action!r}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Add-on parameters that, when present, mean the call mutates add-on
|
|
75
|
+
# configuration (so the read-only middleware must block it). Module-level
|
|
76
|
+
# so the schema-drift guard test (test_read_only.py) can pin an
|
|
77
|
+
# independent manifest against it — see item 10b / ha_manage_addon.
|
|
78
|
+
_ADDON_CONFIG_WRITE_PARAMS = ("options", "network", "boot", "auto_update", "watchdog")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _addon_write(args: dict[str, Any]) -> str | None:
|
|
82
|
+
action = args.get("action")
|
|
83
|
+
if action:
|
|
84
|
+
return f"action={action!r}"
|
|
85
|
+
for param in _ADDON_CONFIG_WRITE_PARAMS:
|
|
86
|
+
if args.get(param) is not None:
|
|
87
|
+
return f"add-on configuration change ({param}=...)"
|
|
88
|
+
if args.get("array_patch") is not None:
|
|
89
|
+
return "array_patch modification"
|
|
90
|
+
if args.get("websocket"):
|
|
91
|
+
# A WebSocket session's initial message can command mutations
|
|
92
|
+
# (e.g. ESPHome /compile), so it is not statically classifiable
|
|
93
|
+
# as a read — fail closed.
|
|
94
|
+
return "WebSocket proxy session"
|
|
95
|
+
method = str(args.get("method") or "GET").strip().upper()
|
|
96
|
+
if method != "GET":
|
|
97
|
+
return f"HTTP {method} proxy request"
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _energy_write(args: dict[str, Any]) -> str | None:
|
|
102
|
+
mode = args.get("mode")
|
|
103
|
+
if mode == "get":
|
|
104
|
+
return None
|
|
105
|
+
# dry_run=True previews validate/simulate without saving (every
|
|
106
|
+
# write mode short-circuits before energy/save_prefs). Strict
|
|
107
|
+
# ``is True``: the middleware sees RAW pre-validation arguments, so
|
|
108
|
+
# a non-bool truthy value (e.g. the string "false") that schema
|
|
109
|
+
# coercion could turn into False must fail closed here.
|
|
110
|
+
if args.get("dry_run") is True:
|
|
111
|
+
return None
|
|
112
|
+
return f"mode={mode!r}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _pipeline_write(args: dict[str, Any]) -> str | None:
|
|
116
|
+
action = args.get("action")
|
|
117
|
+
if action in ("list", "get"):
|
|
118
|
+
return None
|
|
119
|
+
return f"action={action!r}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _custom_tool_write(args: dict[str, Any]) -> str | None:
|
|
123
|
+
if args.get("list_saved") and not args.get("code") and not args.get("run_saved"):
|
|
124
|
+
return None
|
|
125
|
+
# Sandbox execution gets api_post / ws_send bridges that can write
|
|
126
|
+
# to HA directly, so running code (new or saved) is never a read.
|
|
127
|
+
return "sandbox code execution"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Mixed read/write tools whose read surface has no pure-read duplicate
|
|
131
|
+
# (verified per tool: ha_get_addon cannot proxy-read addon-internal
|
|
132
|
+
# APIs; energy prefs and assist pipelines are reachable only through
|
|
133
|
+
# these tools; edit-backup listing exists nowhere else; the saved-tools
|
|
134
|
+
# cache is only listable here). Everything NOT in this table and not
|
|
135
|
+
# ``readOnlyHint=True`` is hidden and blocked outright.
|
|
136
|
+
#
|
|
137
|
+
# ``MANDATORY_TOOLS`` (settings_ui.py) intentionally needs no special
|
|
138
|
+
# case here: every mandatory tool is either ``readOnlyHint=True`` or
|
|
139
|
+
# present in this table (``ha_manage_backup``). The e2e test
|
|
140
|
+
# ``test_real_catalog_mandatory_tools_stay_available``
|
|
141
|
+
# (tests/src/e2e/policy/test_readonly_mode.py) guards that invariant
|
|
142
|
+
# against the real registered catalog at PR time, so the two sets
|
|
143
|
+
# cannot drift apart silently.
|
|
144
|
+
READ_ONLY_EXEMPT_TOOLS: dict[str, ReadOnlyExemption] = {
|
|
145
|
+
"ha_manage_backup": ReadOnlyExemption(
|
|
146
|
+
_backup_write,
|
|
147
|
+
"listing and viewing per-edit backups (scope='edits', action='list' or 'view')",
|
|
148
|
+
),
|
|
149
|
+
"ha_manage_addon": ReadOnlyExemption(
|
|
150
|
+
_addon_write,
|
|
151
|
+
"HTTP GET proxy reads of add-on APIs (slug + path, method='GET')",
|
|
152
|
+
),
|
|
153
|
+
"ha_manage_energy_prefs": ReadOnlyExemption(
|
|
154
|
+
_energy_write,
|
|
155
|
+
"reading the energy configuration (mode='get') and dry-run "
|
|
156
|
+
"previews (dry_run=true)",
|
|
157
|
+
),
|
|
158
|
+
"ha_manage_pipeline": ReadOnlyExemption(
|
|
159
|
+
_pipeline_write,
|
|
160
|
+
"listing and inspecting pipelines (action='list' or 'get')",
|
|
161
|
+
),
|
|
162
|
+
"ha_manage_custom_tool": ReadOnlyExemption(
|
|
163
|
+
_custom_tool_write,
|
|
164
|
+
"listing saved tools (list_saved=True)",
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def is_read_safe(tool: Tool) -> bool:
|
|
170
|
+
"""Return True when the tool's annotations declare it read-only."""
|
|
171
|
+
annotations = getattr(tool, "annotations", None)
|
|
172
|
+
return bool(annotations and getattr(annotations, "readOnlyHint", None) is True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def read_only_visible(tool: Tool) -> bool:
|
|
176
|
+
"""Return True when the tool stays in the catalog in read-only mode."""
|
|
177
|
+
return is_read_safe(tool) or tool.name in READ_ONLY_EXEMPT_TOOLS
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _raise_read_only_error(
|
|
181
|
+
name: str, *, blocked_operation: str | None = None, allowed: str | None = None
|
|
182
|
+
) -> NoReturn:
|
|
183
|
+
context: dict[str, Any] = {"tool_name": name, "read_only_mode": True}
|
|
184
|
+
if blocked_operation is not None:
|
|
185
|
+
context["blocked_operation"] = blocked_operation
|
|
186
|
+
if blocked_operation is not None and allowed is not None:
|
|
187
|
+
message = (
|
|
188
|
+
f"Read Only Mode is enabled on this Home Assistant MCP server. "
|
|
189
|
+
f"This call to '{name}' is a write operation "
|
|
190
|
+
f"({blocked_operation}) and was blocked — no changes were made. "
|
|
191
|
+
f"While Read Only Mode is on, '{name}' only supports: {allowed}."
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
message = (
|
|
195
|
+
f"Read Only Mode is enabled on this Home Assistant MCP server. "
|
|
196
|
+
f"'{name}' is a write-capable tool, so the call was blocked — "
|
|
197
|
+
f"no changes were made."
|
|
198
|
+
)
|
|
199
|
+
raise_tool_error(
|
|
200
|
+
create_error_response(
|
|
201
|
+
ErrorCode.READ_ONLY_MODE,
|
|
202
|
+
message,
|
|
203
|
+
suggestions=[
|
|
204
|
+
"Continue with read-only tools — searching, getting, and "
|
|
205
|
+
+ "listing data all remain available.",
|
|
206
|
+
"If the user wants to allow changes, they must turn off "
|
|
207
|
+
+ "Read Only Mode in the ha-mcp settings UI (Tools tab) or "
|
|
208
|
+
+ "the add-on configuration.",
|
|
209
|
+
],
|
|
210
|
+
context=context,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ReadOnlyToolsTransform(Transform):
|
|
216
|
+
"""Hide write-capable tools from the catalog while read-only mode is on.
|
|
217
|
+
|
|
218
|
+
Installed before the search transforms so the BM25 index never
|
|
219
|
+
indexes hidden write tools. Consults the live flag per request —
|
|
220
|
+
no-op (and no per-call cost beyond the flag check) while it is off.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
|
|
224
|
+
if not get_global_settings().read_only_mode:
|
|
225
|
+
return tools
|
|
226
|
+
return [t for t in tools if read_only_visible(t)]
|
|
227
|
+
|
|
228
|
+
async def get_tool(
|
|
229
|
+
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
|
|
230
|
+
) -> Tool | None:
|
|
231
|
+
tool = await call_next(name, version=version)
|
|
232
|
+
if tool is None or not get_global_settings().read_only_mode:
|
|
233
|
+
return tool
|
|
234
|
+
return tool if read_only_visible(tool) else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ReadOnlyMiddleware(Middleware):
|
|
238
|
+
"""Block write operations at call time while read-only mode is on.
|
|
239
|
+
|
|
240
|
+
The catalog filter already hides plain write tools, but the
|
|
241
|
+
middleware is the actual enforcement: it covers calls routed through
|
|
242
|
+
the search proxies, the write actions of the exempt mixed tools, and
|
|
243
|
+
direct calls to hidden tools. Annotation lookups go through an
|
|
244
|
+
unfiltered catalog provider (injected by server.py) and are cached
|
|
245
|
+
(rebuilt on a cache miss — see _classify).
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(self, *, list_tools: Callable[[], Awaitable[Sequence[Tool]]]) -> None:
|
|
249
|
+
self._list_tools = list_tools
|
|
250
|
+
self._read_safe_cache: dict[str, bool] | None = None
|
|
251
|
+
|
|
252
|
+
async def _classify(self, name: str) -> str:
|
|
253
|
+
"""Classify ``name`` as 'read', 'write', or 'unknown'.
|
|
254
|
+
|
|
255
|
+
Backed by the unfiltered catalog; the cache rebuilds on a miss so
|
|
256
|
+
late-registered tools classify correctly. 'unknown' means the
|
|
257
|
+
tool is not registered at all — the call passes through so the
|
|
258
|
+
caller gets the normal unknown-tool error (nothing executable,
|
|
259
|
+
no write risk). An EMPTY catalog is abnormal (broken lookup) and
|
|
260
|
+
classifies everything 'write' — fail closed rather than letting
|
|
261
|
+
calls through unclassified. If the catalog lookup itself RAISES
|
|
262
|
+
we cannot classify anything, so we block the call with the
|
|
263
|
+
structured READ_ONLY_MODE error rather than let the exception
|
|
264
|
+
propagate opaquely (or, worse, let a future try/except return
|
|
265
|
+
'unknown' and silently fail open).
|
|
266
|
+
"""
|
|
267
|
+
if self._read_safe_cache is None or name not in self._read_safe_cache:
|
|
268
|
+
try:
|
|
269
|
+
tools = await self._list_tools()
|
|
270
|
+
except Exception:
|
|
271
|
+
logger.exception(
|
|
272
|
+
"read-only mode: tool catalog lookup failed while "
|
|
273
|
+
"classifying %s — blocking the call conservatively",
|
|
274
|
+
name,
|
|
275
|
+
)
|
|
276
|
+
raise_tool_error(
|
|
277
|
+
create_error_response(
|
|
278
|
+
ErrorCode.READ_ONLY_MODE,
|
|
279
|
+
f"Read Only Mode is enabled on this Home Assistant MCP "
|
|
280
|
+
f"server, and the tool catalog lookup needed to classify "
|
|
281
|
+
f"'{name}' as read or write failed. The call to '{name}' "
|
|
282
|
+
f"was blocked conservatively — no changes were made.",
|
|
283
|
+
suggestions=[
|
|
284
|
+
"Retry the call — the catalog lookup may succeed "
|
|
285
|
+
+ "on the next attempt.",
|
|
286
|
+
"If this persists, the MCP server may be "
|
|
287
|
+
+ "misconfigured; check the server logs.",
|
|
288
|
+
],
|
|
289
|
+
context={"tool_name": name, "read_only_mode": True},
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
self._read_safe_cache = {t.name: is_read_safe(t) for t in tools}
|
|
293
|
+
if name in self._read_safe_cache:
|
|
294
|
+
return "read" if self._read_safe_cache[name] else "write"
|
|
295
|
+
if not self._read_safe_cache:
|
|
296
|
+
return "write"
|
|
297
|
+
return "unknown"
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _coerce_arguments(arguments: Any) -> dict[str, Any] | None:
|
|
301
|
+
"""Normalise a proxy envelope's ``arguments`` to a dict or None.
|
|
302
|
+
|
|
303
|
+
The categorized proxies tolerate ``arguments`` arriving as a JSON
|
|
304
|
+
string (small models sometimes serialize it) and json.loads it
|
|
305
|
+
AFTER this middleware runs (categorized_search.py ~313-352). So:
|
|
306
|
+
a dict is used as-is; an absent value (``None``) becomes ``{}``
|
|
307
|
+
(a legitimate no-argument call, not malformed); a string is
|
|
308
|
+
parsed and only a JSON *object* yields a dict. Anything else — an
|
|
309
|
+
unparseable string, a non-dict JSON value (list/scalar/null), or
|
|
310
|
+
a non-str/non-dict type — returns None, meaning "malformed
|
|
311
|
+
envelope".
|
|
312
|
+
"""
|
|
313
|
+
if isinstance(arguments, dict):
|
|
314
|
+
return arguments
|
|
315
|
+
if arguments is None:
|
|
316
|
+
return {}
|
|
317
|
+
if isinstance(arguments, str):
|
|
318
|
+
try:
|
|
319
|
+
parsed = json.loads(arguments)
|
|
320
|
+
except (json.JSONDecodeError, ValueError):
|
|
321
|
+
return None
|
|
322
|
+
return parsed if isinstance(parsed, dict) else None
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def _unwrap_proxy_call(
|
|
327
|
+
cls,
|
|
328
|
+
args: dict[str, Any],
|
|
329
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
330
|
+
"""Extract the inner (tool, arguments) from a call-proxy envelope.
|
|
331
|
+
|
|
332
|
+
The categorized call proxies validate the inner name against
|
|
333
|
+
their category caches BEFORE dispatching — and in read-only mode
|
|
334
|
+
those caches no longer contain the hidden write tools, so a
|
|
335
|
+
proxied write would surface as a generic "tool not found" error
|
|
336
|
+
instead of the explanatory READ_ONLY_MODE one. Unwrapping here
|
|
337
|
+
lets the middleware decide on the inner call first. Mirrors the
|
|
338
|
+
proxy's own double-wrap unwrapping.
|
|
339
|
+
|
|
340
|
+
``arguments`` is coerced at every level (see _coerce_arguments)
|
|
341
|
+
because the proxy accepts it as a JSON string. Returns None when
|
|
342
|
+
there is no usable envelope — including when ``arguments`` is a
|
|
343
|
+
MALFORMED string (json.loads fails, or the JSON is not an object).
|
|
344
|
+
Passing through on a malformed envelope is safe: the proxy
|
|
345
|
+
rejects such ``arguments`` with its own VALIDATION error BEFORE
|
|
346
|
+
the category check and before any dispatch
|
|
347
|
+
(categorized_search.py ~313-352), so no write can occur.
|
|
348
|
+
"""
|
|
349
|
+
name = args.get("name")
|
|
350
|
+
arguments = cls._coerce_arguments(args.get("arguments"))
|
|
351
|
+
while (
|
|
352
|
+
isinstance(name, str)
|
|
353
|
+
and name in PROXY_META_TOOLS
|
|
354
|
+
and isinstance(arguments, dict)
|
|
355
|
+
and isinstance(arguments.get("name"), str)
|
|
356
|
+
):
|
|
357
|
+
name = arguments.get("name")
|
|
358
|
+
arguments = cls._coerce_arguments(arguments.get("arguments"))
|
|
359
|
+
if not isinstance(name, str):
|
|
360
|
+
return None
|
|
361
|
+
if arguments is None:
|
|
362
|
+
# Malformed inner ``arguments`` (a string that fails
|
|
363
|
+
# json.loads, or a non-object JSON value / non-dict type).
|
|
364
|
+
# Pass through: the proxy raises its own VALIDATION error
|
|
365
|
+
# before any dispatch, so nothing can be written. (An absent
|
|
366
|
+
# ``arguments`` key is NOT malformed — it coerces to ``{}``.)
|
|
367
|
+
return None
|
|
368
|
+
return name, arguments
|
|
369
|
+
|
|
370
|
+
async def on_call_tool(
|
|
371
|
+
self, context: MiddlewareContext, call_next: CallNext
|
|
372
|
+
) -> Any:
|
|
373
|
+
if not get_global_settings().read_only_mode:
|
|
374
|
+
return await call_next(context)
|
|
375
|
+
|
|
376
|
+
name = context.message.name
|
|
377
|
+
args = context.message.arguments or {}
|
|
378
|
+
|
|
379
|
+
# Call proxies: decide on the INNER call (see _unwrap_proxy_call).
|
|
380
|
+
# ha_search_tools and envelope-less proxy calls pass through —
|
|
381
|
+
# searching is a read, and the proxy raises its own validation
|
|
382
|
+
# error for a missing inner name. When the inner call is allowed,
|
|
383
|
+
# the proxy dispatch re-enters this middleware with the real tool
|
|
384
|
+
# name anyway (harmless re-check, same verdict).
|
|
385
|
+
if name in PROXY_META_TOOLS:
|
|
386
|
+
unwrapped = self._unwrap_proxy_call(args)
|
|
387
|
+
if unwrapped is None:
|
|
388
|
+
return await call_next(context)
|
|
389
|
+
inner_name, inner_args = unwrapped
|
|
390
|
+
exemption = READ_ONLY_EXEMPT_TOOLS.get(inner_name)
|
|
391
|
+
if exemption is not None:
|
|
392
|
+
blocked = exemption.blocked_write(inner_args)
|
|
393
|
+
if blocked is None:
|
|
394
|
+
return await call_next(context)
|
|
395
|
+
logger.info(
|
|
396
|
+
"read-only mode blocked proxied write operation of %s (%s)",
|
|
397
|
+
inner_name,
|
|
398
|
+
blocked,
|
|
399
|
+
)
|
|
400
|
+
_raise_read_only_error(
|
|
401
|
+
inner_name, blocked_operation=blocked, allowed=exemption.allowed
|
|
402
|
+
)
|
|
403
|
+
if await self._classify(inner_name) != "write":
|
|
404
|
+
# 'read' is allowed; 'unknown' falls through to the
|
|
405
|
+
# proxy's own not-found error.
|
|
406
|
+
return await call_next(context)
|
|
407
|
+
logger.info(
|
|
408
|
+
"read-only mode blocked proxied call to write tool %s", inner_name
|
|
409
|
+
)
|
|
410
|
+
_raise_read_only_error(inner_name)
|
|
411
|
+
|
|
412
|
+
exemption = READ_ONLY_EXEMPT_TOOLS.get(name)
|
|
413
|
+
if exemption is not None:
|
|
414
|
+
blocked = exemption.blocked_write(args)
|
|
415
|
+
if blocked is None:
|
|
416
|
+
return await call_next(context)
|
|
417
|
+
logger.info(
|
|
418
|
+
"read-only mode blocked write operation of %s (%s)", name, blocked
|
|
419
|
+
)
|
|
420
|
+
_raise_read_only_error(
|
|
421
|
+
name, blocked_operation=blocked, allowed=exemption.allowed
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if await self._classify(name) != "write":
|
|
425
|
+
# 'read' is allowed; 'unknown' falls through to FastMCP's
|
|
426
|
+
# normal unknown-tool error.
|
|
427
|
+
return await call_next(context)
|
|
428
|
+
|
|
429
|
+
logger.info("read-only mode blocked call to write tool %s", name)
|
|
430
|
+
_raise_read_only_error(name)
|
|
431
|
+
return None # py/mixed-returns: unreachable, _raise_read_only_error raises
|
|
@@ -118,6 +118,26 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
118
118
|
# Build server instructions from bundled skills (if enabled)
|
|
119
119
|
instructions = self._build_skills_instructions()
|
|
120
120
|
|
|
121
|
+
# Surface Read Only Mode in the startup instructions so clients
|
|
122
|
+
# that show server instructions warn the model up front. Startup
|
|
123
|
+
# state only — live flips are covered by the structured
|
|
124
|
+
# READ_ONLY_MODE call errors and the ha_get_overview field.
|
|
125
|
+
if self.settings.read_only_mode:
|
|
126
|
+
read_only_note = (
|
|
127
|
+
"## Read Only Mode\n"
|
|
128
|
+
"This server is running in Read Only Mode: write-capable "
|
|
129
|
+
"tools are disabled and every write or destructive "
|
|
130
|
+
"operation is blocked with a READ_ONLY_MODE error. You can "
|
|
131
|
+
"search, read, and analyze freely. To allow changes, the "
|
|
132
|
+
"user must turn off Read Only Mode in the ha-mcp settings "
|
|
133
|
+
"UI (Tools tab) or the add-on configuration."
|
|
134
|
+
)
|
|
135
|
+
instructions = (
|
|
136
|
+
f"{instructions}\n\n{read_only_note}"
|
|
137
|
+
if instructions
|
|
138
|
+
else read_only_note
|
|
139
|
+
)
|
|
140
|
+
|
|
121
141
|
# Create FastMCP server with Home Assistant icons for client UI display
|
|
122
142
|
self.mcp = FastMCP(
|
|
123
143
|
name=server_name,
|
|
@@ -187,6 +207,12 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
187
207
|
# search indexing too).
|
|
188
208
|
self._apply_settings_visibility()
|
|
189
209
|
|
|
210
|
+
# Read Only Mode catalog filter (discussion #1569) — always
|
|
211
|
+
# installed, consults the live flag per request. Must come
|
|
212
|
+
# before the search transforms so the BM25 index never indexes
|
|
213
|
+
# hidden write tools while the mode is on.
|
|
214
|
+
self._apply_read_only_catalog_filter()
|
|
215
|
+
|
|
190
216
|
# Replace heavy tool descriptions with lite variants when
|
|
191
217
|
# ENABLE_LITE_DOCSTRINGS=true. Must come BEFORE keyword
|
|
192
218
|
# enrichment so BM25 keywords append to the lite text (instead
|
|
@@ -210,6 +236,12 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
210
236
|
|
|
211
237
|
self.mcp.add_middleware(ValidationErrorMiddleware())
|
|
212
238
|
|
|
239
|
+
# Read Only Mode write blocker (discussion #1569) — always
|
|
240
|
+
# installed, consults the live flag per call. Before
|
|
241
|
+
# PolicyMiddleware so a write blocked by Read Only Mode never
|
|
242
|
+
# queues a pointless approval request.
|
|
243
|
+
self._apply_read_only_middleware()
|
|
244
|
+
|
|
213
245
|
# Wire tool security policies middleware (#966) — opt-in via
|
|
214
246
|
# ENABLE_TOOL_SECURITY_POLICIES. Must come last so the middleware
|
|
215
247
|
# wraps the final tool surface (including the search proxies).
|
|
@@ -939,6 +971,42 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
939
971
|
data_dir,
|
|
940
972
|
)
|
|
941
973
|
|
|
974
|
+
def _apply_read_only_catalog_filter(self) -> None:
|
|
975
|
+
"""Install the Read Only Mode catalog filter (discussion #1569).
|
|
976
|
+
|
|
977
|
+
Always installed — :class:`ReadOnlyToolsTransform` consults the
|
|
978
|
+
live ``read_only_mode`` flag per list request, so it is a no-op
|
|
979
|
+
while the mode is off and standalone-mode toggles take effect
|
|
980
|
+
without a restart. Hides write-capable tools (``readOnlyHint``
|
|
981
|
+
not True) except the exempt mixed read/write tools, whose write
|
|
982
|
+
actions ``ReadOnlyMiddleware`` blocks at call time instead.
|
|
983
|
+
"""
|
|
984
|
+
from .read_only import ReadOnlyToolsTransform
|
|
985
|
+
|
|
986
|
+
self.mcp.add_transform(ReadOnlyToolsTransform())
|
|
987
|
+
if self.settings.read_only_mode:
|
|
988
|
+
logger.info(
|
|
989
|
+
"Read Only Mode is ON — write-capable tools are hidden "
|
|
990
|
+
"and write operations are blocked"
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
def _apply_read_only_middleware(self) -> None:
|
|
994
|
+
"""Install the Read Only Mode write blocker (discussion #1569).
|
|
995
|
+
|
|
996
|
+
Always installed — consults the live flag per call, so there is
|
|
997
|
+
no per-call work beyond the flag check while the mode is off.
|
|
998
|
+
The annotation lookup uses the UNFILTERED tool list (private
|
|
999
|
+
FastMCP API, same justified usage as the settings UI and policy
|
|
1000
|
+
handlers) so hidden tools still resolve to a clear structured
|
|
1001
|
+
error instead of an opaque unknown-tool failure.
|
|
1002
|
+
"""
|
|
1003
|
+
from .read_only import ReadOnlyMiddleware
|
|
1004
|
+
|
|
1005
|
+
async def _list_all_tools() -> Any:
|
|
1006
|
+
return await self.mcp.local_provider._list_tools()
|
|
1007
|
+
|
|
1008
|
+
self.mcp.add_middleware(ReadOnlyMiddleware(list_tools=_list_all_tools))
|
|
1009
|
+
|
|
942
1010
|
# Shared action-phrased keyword block for retrieval. Some MCP clients
|
|
943
1011
|
# (Claude Code, others) rank candidate tools by token-overlap between
|
|
944
1012
|
# the user's natural-language query and each tool's `description`
|
|
@@ -187,6 +187,12 @@
|
|
|
187
187
|
color: var(--text); font-size: 0.85rem; }
|
|
188
188
|
.feature-control input[type="number"]:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
189
189
|
.feature-row.locked .feature-name { color: var(--text-secondary); }
|
|
190
|
+
/* Read Only Mode toggle row sits in the Tools tab between the summary
|
|
191
|
+
and the search box — card styling to match the tab's other blocks
|
|
192
|
+
(the bare .feature-row look is designed for the Server Settings
|
|
193
|
+
list, where rows separate with border-top). */
|
|
194
|
+
#readOnlyModeRow { background: var(--surface); border: 1px solid var(--border);
|
|
195
|
+
border-radius: 10px; padding: 10px 16px; margin-bottom: 16px; }
|
|
190
196
|
/* Beta master toggle + nested sub-rows. The master row
|
|
191
197
|
``.beta-master-row`` is visually distinguished as a section
|
|
192
198
|
header. The 5 sub-rows ``.beta-sub`` are indented with a vertical
|