ha-mcp-dev 7.4.1.dev438__tar.gz → 7.4.1.dev439__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {ha_mcp_dev-7.4.1.dev438/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev439}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/settings_ui.py +51 -27
  4. ha_mcp_dev-7.4.1.dev439/src/ha_mcp/utils/data_paths.py +135 -0
  5. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/usage_logger.py +30 -13
  6. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  7. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  8. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/LICENSE +0 -0
  9. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/MANIFEST.in +0 -0
  10. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/README.md +0 -0
  11. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/setup.cfg +0 -0
  12. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/__init__.py +0 -0
  13. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/__main__.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/_pypi_marker +0 -0
  15. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/_version.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/client/websocket_client.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/client/websocket_listener.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/config.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/errors.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/py.typed +0 -0
  26. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  27. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  28. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  29. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  31. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  34. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  37. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  43. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  45. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  46. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/server.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/smoke_test.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/__init__.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/backup.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/device_control.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/enhanced.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/helpers.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/reference_validator.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/registry.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/smart_search.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_addons.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_calendar.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_camera.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_categories.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_energy.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_entities.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_groups.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_hacs.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_history.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_integrations.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_labels.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_registry.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_resources.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_search.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_service.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_services.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_system.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_todo.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_traces.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_updates.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_utility.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/tools_zones.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/tools/util_helpers.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/transforms/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/transforms/categorized_search.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/__init__.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/config_hash.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/domain_handlers.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/operation_manager.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp/utils/python_sandbox.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/tests/__init__.py +0 -0
  106. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/tests/test_constants.py +0 -0
  107. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev439}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev438
3
+ Version: 7.4.1.dev439
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev438"
7
+ version = "7.4.1.dev439"
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"
@@ -19,8 +19,10 @@ import httpx
19
19
  from starlette.requests import Request
20
20
  from starlette.responses import HTMLResponse, JSONResponse
21
21
 
22
+ from ._version import is_running_in_addon
22
23
  from .errors import ErrorCode, create_error_response
23
24
  from .transforms import DEFAULT_PINNED_TOOLS
25
+ from .utils.data_paths import get_data_dir
24
26
 
25
27
  if TYPE_CHECKING:
26
28
  from fastmcp import FastMCP
@@ -99,36 +101,38 @@ FEATURE_GATED_TOOLS: dict[str, dict[str, str]] = {
99
101
  }
100
102
 
101
103
 
102
- def _is_addon() -> bool:
103
- """Return True when running inside the Home Assistant add-on container.
104
+ def _get_config_path() -> Path:
105
+ """Return the path to the tool config JSON file.
104
106
 
105
- Mirrors the existing convention in this module (and ``__main__.py``)
106
- of treating ``SUPERVISOR_TOKEN`` as the add-on detector. Using the env
107
- var is more reliable than checking for ``/data`` because some Docker
108
- setups (and macOS dev environments) have a ``/data`` directory that
109
- isn't the add-on data dir.
107
+ Delegates directory resolution to :func:`utils.data_paths.get_data_dir`,
108
+ which handles ``HA_MCP_CONFIG_DIR`` override, add-on ``/data``,
109
+ home-dir, and tmpdir fallback (memoized).
110
110
  """
111
- return bool(os.environ.get("SUPERVISOR_TOKEN"))
112
-
113
-
114
- def _get_config_path() -> Path:
115
- """Return the path to the tool config JSON file."""
116
- if _is_addon():
117
- return Path("/data") / "tool_config.json"
118
- home_dir = Path.home() / ".ha-mcp"
119
- home_dir.mkdir(parents=True, exist_ok=True)
120
- return home_dir / "tool_config.json"
111
+ return get_data_dir() / "tool_config.json"
121
112
 
122
113
 
123
114
  def load_tool_config(settings: Settings | None = None) -> dict[str, Any]:
124
115
  """Load persisted tool config, seeding from env vars if no file exists."""
125
116
  path = _get_config_path()
126
- if path.exists():
117
+ # ``Path.exists()`` only swallows ``ENOENT/ENOTDIR/EBADF/ELOOP``; an
118
+ # ``EACCES`` (e.g. ``HA_MCP_CONFIG_DIR`` pointing at a dir that exists
119
+ # but isn't readable by the runtime UID) propagates. Read directly and
120
+ # treat ``FileNotFoundError`` as "no config yet"; log other ``OSError``s.
121
+ try:
122
+ raw = path.read_text()
123
+ except FileNotFoundError:
124
+ raw = None
125
+ except OSError:
126
+ logger.warning("Cannot read tool config at %s", path, exc_info=True)
127
+ raw = None
128
+
129
+ if raw is not None:
127
130
  try:
128
- result: dict[str, Any] = json.loads(path.read_text())
131
+ result: dict[str, Any] = json.loads(raw)
132
+ except json.JSONDecodeError:
133
+ logger.warning("Tool config at %s is not valid JSON; ignoring.", path)
134
+ else:
129
135
  return result
130
- except (OSError, json.JSONDecodeError):
131
- logger.warning("Failed to read tool config from %s", path)
132
136
 
133
137
  if settings is None:
134
138
  return {}
@@ -156,14 +160,23 @@ def load_tool_config(settings: Settings | None = None) -> dict[str, Any]:
156
160
  return {}
157
161
 
158
162
 
159
- def save_tool_config(config: dict[str, Any]) -> None:
160
- """Persist tool config to disk."""
163
+ def save_tool_config(config: dict[str, Any]) -> bool:
164
+ """Persist tool config to disk.
165
+
166
+ Returns True on success, False on failure (read-only filesystem,
167
+ permission denied, etc.). Caller is responsible for surfacing the
168
+ failure to the user — the HTTP route at ``_save_tools`` returns 500
169
+ so the UI's ``saveConfig`` shows "Save failed!" instead of the
170
+ misleading "Saved — restart required".
171
+ """
161
172
  path = _get_config_path()
162
173
  try:
163
174
  path.write_text(json.dumps(config, indent=2))
164
- logger.info("Saved tool config to %s", path)
165
175
  except OSError:
166
176
  logger.exception("Failed to save tool config to %s", path)
177
+ return False
178
+ logger.info("Saved tool config to %s", path)
179
+ return True
167
180
 
168
181
 
169
182
  async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[str, Any]]:
@@ -760,7 +773,18 @@ def register_settings_routes(
760
773
 
761
774
  config = load_tool_config()
762
775
  config["tools"] = states
763
- save_tool_config(config)
776
+ if not save_tool_config(config):
777
+ return JSONResponse(
778
+ create_error_response(
779
+ ErrorCode.INTERNAL_ERROR,
780
+ "Failed to persist tool config to disk",
781
+ suggestions=[
782
+ "Set HA_MCP_CONFIG_DIR to a writable path (read-only filesystem?)",
783
+ "Check the server logs for the underlying OSError",
784
+ ],
785
+ ),
786
+ status_code=500,
787
+ )
764
788
 
765
789
  disabled_count = sum(1 for s in states.values() if s == "disabled")
766
790
  pinned_count = sum(1 for s in states.values() if s == "pinned")
@@ -823,11 +847,11 @@ def register_settings_routes(
823
847
 
824
848
  async def _settings_info(_: Request) -> JSONResponse:
825
849
  return JSONResponse({
826
- "is_addon": _is_addon(),
850
+ "is_addon": is_running_in_addon(),
827
851
  })
828
852
 
829
853
  secret_prefix = secret_path.rstrip("/") if secret_path else ""
830
- is_addon = _is_addon()
854
+ is_addon = is_running_in_addon()
831
855
 
832
856
  if not is_addon and not secret_prefix:
833
857
  logger.warning(
@@ -0,0 +1,135 @@
1
+ """Resolve a writable directory for ha-mcp persistent data.
2
+
3
+ Single source of truth for "where does ha-mcp write its files?" — used
4
+ by both ``settings_ui`` (tool config) and ``usage_logger`` (rolling
5
+ JSONL).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import logging
12
+ import os
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from .._version import is_running_in_addon
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @functools.lru_cache(maxsize=1)
22
+ def get_data_dir() -> Path:
23
+ """Return a writable directory for ha-mcp persistent data (memoized).
24
+
25
+ Resolution order:
26
+
27
+ 1. ``HA_MCP_CONFIG_DIR`` env var — explicit override, e.g. for hardened
28
+ Docker setups bind-mounting a writable volume into a
29
+ ``read_only: true`` container.
30
+ 2. ``/data`` — Home Assistant add-on (``SUPERVISOR_TOKEN`` set; writable
31
+ supervisor data dir).
32
+ 3. ``~/.ha-mcp`` — standard install. Skipped when ``HA_MCP_CONFIG_DIR``
33
+ was set but failed: an explicit override means "use this exact
34
+ location", and silently writing to ``$HOME`` instead would surprise
35
+ users who chose the override deliberately.
36
+ 4. ``<tempdir>/ha-mcp`` — last-resort fallback when the previously
37
+ chosen step fails (read-only filesystem; ``HOME`` unset so
38
+ ``Path.home()`` resolves to ``/``; or ``HA_MCP_CONFIG_DIR`` set but
39
+ its mkdir raises). Loses persistence across restarts but lets the
40
+ server start; users wanting persistence should set
41
+ ``HA_MCP_CONFIG_DIR`` to a writable path.
42
+
43
+ Memoized so the fallback warning typically emits once at startup
44
+ rather than on every save/load HTTP request. ``lru_cache`` serializes
45
+ its internal dict but does not serialize the wrapped call when the
46
+ cache is empty, so two threads racing on first access (e.g.
47
+ ``UsageLogger.__init__`` from a worker thread plus a settings UI HTTP
48
+ handler) may each run ``_resolve_data_dir`` once and emit the warning
49
+ twice. The mkdir calls are idempotent, so this is cosmetic.
50
+ Tests reset via ``get_data_dir.cache_clear()``.
51
+ """
52
+ return _resolve_data_dir()
53
+
54
+
55
+ def _resolve_data_dir() -> Path:
56
+ """Resolve the data directory (uncached); see ``get_data_dir`` for priority."""
57
+ # ``.strip()``: ``HA_MCP_CONFIG_DIR=" "`` is truthy and ``Path(" ")``
58
+ # resolves cwd-relative, which would mkdir a literal whitespace-named
59
+ # directory next to whatever cwd happens to be at startup.
60
+ config_dir_env = os.environ.get("HA_MCP_CONFIG_DIR", "").strip()
61
+ preferred: Path | None = None
62
+ if config_dir_env:
63
+ custom_dir = Path(config_dir_env)
64
+ try:
65
+ custom_dir.mkdir(parents=True, exist_ok=True)
66
+ except OSError as e:
67
+ logger.warning(
68
+ "HA_MCP_CONFIG_DIR=%s could not be prepared (%s: %s); "
69
+ "falling back to a tmpdir.",
70
+ custom_dir,
71
+ type(e).__name__,
72
+ e,
73
+ )
74
+ preferred = custom_dir
75
+ else:
76
+ return custom_dir
77
+
78
+ if is_running_in_addon():
79
+ addon_dir = Path("/data")
80
+ try:
81
+ addon_dir.mkdir(parents=True, exist_ok=True)
82
+ except OSError as e:
83
+ logger.warning(
84
+ "/data is not writable in add-on mode (%s: %s); "
85
+ "falling back. Set HA_MCP_CONFIG_DIR to override.",
86
+ type(e).__name__,
87
+ e,
88
+ )
89
+ if preferred is None:
90
+ preferred = addon_dir
91
+ else:
92
+ # Honor an explicit HA_MCP_CONFIG_DIR override even in add-on
93
+ # mode: if the user set it and its mkdir failed (preferred is
94
+ # not None), fall through to the tmpdir fallback rather than
95
+ # silently writing to /data — they chose the override
96
+ # deliberately.
97
+ if preferred is None:
98
+ return addon_dir
99
+
100
+ if preferred is None:
101
+ home_dir = Path.home() / ".ha-mcp"
102
+ try:
103
+ home_dir.mkdir(parents=True, exist_ok=True)
104
+ except OSError:
105
+ preferred = home_dir
106
+ else:
107
+ return home_dir
108
+
109
+ fallback = Path(tempfile.gettempdir()) / "ha-mcp"
110
+ try:
111
+ fallback.mkdir(parents=True, exist_ok=True)
112
+ except OSError as e:
113
+ # Even the tmpdir is unwritable. Return the path anyway: callers
114
+ # that wrap writes in try/except OSError can degrade gracefully
115
+ # (no persistence, but the server still starts). ``error`` rather
116
+ # than ``warning`` because persistence is silently disabled — the
117
+ # supervisor log viewer surfaces errors more prominently.
118
+ logger.error(
119
+ "Cannot write ha-mcp data to %s or fallback %s (%s: %s); "
120
+ "persistence is disabled. "
121
+ "Set HA_MCP_CONFIG_DIR to a writable path for persistence.",
122
+ preferred,
123
+ fallback,
124
+ type(e).__name__,
125
+ e,
126
+ )
127
+ else:
128
+ logger.warning(
129
+ "Cannot write ha-mcp data to %s (read-only filesystem or HOME unset). "
130
+ "Falling back to %s — data will NOT persist across restarts. "
131
+ "Set HA_MCP_CONFIG_DIR to a writable path for persistence.",
132
+ preferred,
133
+ fallback,
134
+ )
135
+ return fallback
@@ -13,6 +13,10 @@ from pathlib import Path
13
13
  from queue import Queue
14
14
  from typing import Any
15
15
 
16
+ from .data_paths import get_data_dir
17
+
18
+ logger = logging.getLogger(__name__)
19
+
16
20
  # Default ring buffer size - keeps last N entries in memory
17
21
  DEFAULT_RING_BUFFER_SIZE = 200
18
22
 
@@ -46,13 +50,15 @@ class StartupLogCollector(logging.Handler):
46
50
  return
47
51
 
48
52
  with self._lock:
49
- self._logs.append({
50
- "timestamp": datetime.now(UTC).isoformat(),
51
- "level": record.levelname,
52
- "logger": record.name,
53
- "message": record.getMessage(),
54
- "elapsed_seconds": round(elapsed, 2),
55
- })
53
+ self._logs.append(
54
+ {
55
+ "timestamp": datetime.now(UTC).isoformat(),
56
+ "level": record.levelname,
57
+ "logger": record.name,
58
+ "message": record.getMessage(),
59
+ "elapsed_seconds": round(elapsed, 2),
60
+ }
61
+ )
56
62
 
57
63
  def get_logs(self) -> list[dict[str, Any]]:
58
64
  """Get collected startup logs."""
@@ -115,15 +121,26 @@ class UsageLogger:
115
121
  if log_file_path:
116
122
  self.log_file_path = Path(log_file_path)
117
123
  else:
118
- # Use user's home directory by default to avoid read-only filesystem errors
119
- # when running via uvx/npx which might have read-only CWD
120
- self.log_file_path = Path.home() / ".ha-mcp" / "logs" / "mcp_usage.jsonl"
124
+ # Defer to the shared resolver so logs follow the same precedence
125
+ # as the settings-UI tool config (HA_MCP_CONFIG_DIR > /data >
126
+ # ~/.ha-mcp > tempdir). Avoids polluting the filesystem root when
127
+ # HOME is unset and avoids surprising users who bind-mount a
128
+ # writable volume via HA_MCP_CONFIG_DIR but find logs missing.
129
+ self.log_file_path = get_data_dir() / "logs" / "mcp_usage.jsonl"
121
130
 
122
131
  try:
123
132
  self.log_file_path.parent.mkdir(parents=True, exist_ok=True)
124
- except OSError:
125
- # Directory creation failed (e.g., read-only filesystem)
126
- # Disable logging silently to avoid disrupting the MCP server
133
+ except OSError as e:
134
+ # Directory creation failed (e.g., read-only filesystem). Surface
135
+ # the reason instead of silently dropping every log operators
136
+ # otherwise see an empty mcp_usage.jsonl with no clue why.
137
+ logger.warning(
138
+ "Usage logging disabled — could not create %s (%s: %s). "
139
+ "Set HA_MCP_CONFIG_DIR to a writable path to enable persistence.",
140
+ self.log_file_path.parent,
141
+ type(e).__name__,
142
+ e,
143
+ )
127
144
  self._enabled = False
128
145
 
129
146
  # In-memory ring buffer for fast access to recent logs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev438
3
+ Version: 7.4.1.dev439
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
@@ -87,6 +87,7 @@ src/ha_mcp/transforms/__init__.py
87
87
  src/ha_mcp/transforms/categorized_search.py
88
88
  src/ha_mcp/utils/__init__.py
89
89
  src/ha_mcp/utils/config_hash.py
90
+ src/ha_mcp/utils/data_paths.py
90
91
  src/ha_mcp/utils/domain_handlers.py
91
92
  src/ha_mcp/utils/fuzzy_search.py
92
93
  src/ha_mcp/utils/kill_signal_diagnostics.py