|
21 | 21 |
|
22 | 22 | from .errors import ErrorCode, create_error_response |
23 | 23 | from .transforms import DEFAULT_PINNED_TOOLS |
| 24 | +from .utils.data_paths import get_data_dir |
24 | 25 |
|
25 | 26 | if TYPE_CHECKING: |
26 | 27 | from fastmcp import FastMCP |
@@ -112,23 +113,37 @@ def _is_addon() -> bool: |
112 | 113 |
|
113 | 114 |
|
114 | 115 | 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" |
| 116 | + """Return the path to the tool config JSON file. |
| 117 | +
|
| 118 | + Delegates directory resolution to :func:`utils.data_paths.get_data_dir`, |
| 119 | + which handles ``HA_MCP_CONFIG_DIR`` override, add-on ``/data``, |
| 120 | + home-dir, and tmpdir fallback (memoized). |
| 121 | + """ |
| 122 | + return get_data_dir() / "tool_config.json" |
121 | 123 |
|
122 | 124 |
|
123 | 125 | def load_tool_config(settings: Settings | None = None) -> dict[str, Any]: |
124 | 126 | """Load persisted tool config, seeding from env vars if no file exists.""" |
125 | 127 | path = _get_config_path() |
126 | | - if path.exists(): |
| 128 | + # ``Path.exists()`` only swallows ``ENOENT/ENOTDIR/EBADF/ELOOP``; an |
| 129 | + # ``EACCES`` (e.g. ``HA_MCP_CONFIG_DIR`` pointing at a dir that exists |
| 130 | + # but isn't readable by the runtime UID) propagates. Read directly and |
| 131 | + # treat ``FileNotFoundError`` as "no config yet"; log other ``OSError``s. |
| 132 | + try: |
| 133 | + raw = path.read_text() |
| 134 | + except FileNotFoundError: |
| 135 | + raw = None |
| 136 | + except OSError: |
| 137 | + logger.warning("Cannot read tool config at %s", path) |
| 138 | + raw = None |
| 139 | + |
| 140 | + if raw is not None: |
127 | 141 | try: |
128 | | - result: dict[str, Any] = json.loads(path.read_text()) |
| 142 | + result: dict[str, Any] = json.loads(raw) |
| 143 | + except json.JSONDecodeError: |
| 144 | + logger.warning("Tool config at %s is not valid JSON; ignoring.", path) |
| 145 | + else: |
129 | 146 | return result |
130 | | - except (OSError, json.JSONDecodeError): |
131 | | - logger.warning("Failed to read tool config from %s", path) |
132 | 147 |
|
133 | 148 | if settings is None: |
134 | 149 | return {} |
@@ -156,14 +171,23 @@ def load_tool_config(settings: Settings | None = None) -> dict[str, Any]: |
156 | 171 | return {} |
157 | 172 |
|
158 | 173 |
|
159 | | -def save_tool_config(config: dict[str, Any]) -> None: |
160 | | - """Persist tool config to disk.""" |
| 174 | +def save_tool_config(config: dict[str, Any]) -> bool: |
| 175 | + """Persist tool config to disk. |
| 176 | +
|
| 177 | + Returns True on success, False on failure (read-only filesystem, |
| 178 | + permission denied, etc.). Caller is responsible for surfacing the |
| 179 | + failure to the user — the HTTP route at ``_save_tools`` returns 500 |
| 180 | + so the UI's ``saveConfig`` shows "Save failed!" instead of the |
| 181 | + misleading "Saved — restart required". |
| 182 | + """ |
161 | 183 | path = _get_config_path() |
162 | 184 | try: |
163 | 185 | path.write_text(json.dumps(config, indent=2)) |
164 | | - logger.info("Saved tool config to %s", path) |
165 | 186 | except OSError: |
166 | 187 | logger.exception("Failed to save tool config to %s", path) |
| 188 | + return False |
| 189 | + logger.info("Saved tool config to %s", path) |
| 190 | + return True |
167 | 191 |
|
168 | 192 |
|
169 | 193 | async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[str, Any]]: |
@@ -760,7 +784,18 @@ async def _save_tools(request: Request) -> JSONResponse: |
760 | 784 |
|
761 | 785 | config = load_tool_config() |
762 | 786 | config["tools"] = states |
763 | | - save_tool_config(config) |
| 787 | + if not save_tool_config(config): |
| 788 | + return JSONResponse( |
| 789 | + create_error_response( |
| 790 | + ErrorCode.INTERNAL_ERROR, |
| 791 | + "Failed to persist tool config to disk", |
| 792 | + suggestions=[ |
| 793 | + "Set HA_MCP_CONFIG_DIR to a writable path (read-only filesystem?)", |
| 794 | + "Check the server logs for the underlying OSError", |
| 795 | + ], |
| 796 | + ), |
| 797 | + status_code=500, |
| 798 | + ) |
764 | 799 |
|
765 | 800 | disabled_count = sum(1 for s in states.values() if s == "disabled") |
766 | 801 | pinned_count = sum(1 for s in states.values() if s == "pinned") |
|
0 commit comments