forked from homeassistant-ai/ha-mcp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstart.py
More file actions
executable file
·458 lines (396 loc) · 18.5 KB
/
Copy pathstart.py
File metadata and controls
executable file
·458 lines (396 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
#!/usr/bin/env python3
"""Home Assistant MCP Server Add-on startup script."""
import json
import os
import re
import secrets
import sys
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any, TextIO
def _log_with_timestamp(level: str, message: str, stream: TextIO | None = None) -> None:
"""Log a message with a timestamp."""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"{now} [{level}] {message}", file=stream, flush=True)
def log_info(message: str) -> None:
"""Log info message."""
_log_with_timestamp("INFO", message)
def log_error(message: str) -> None:
"""Log error message."""
_log_with_timestamp("ERROR", message, sys.stderr)
def generate_secret_path() -> str:
"""Generate a secure random path with 128-bit entropy.
Format: /private_<22-char-urlsafe-token>
Example: /private_zctpwlX7ZkIAr7oqdfLPxw
"""
return "/private_" + secrets.token_urlsafe(16)
_SECRET_PATH_RE = re.compile(r"^/(?!.*://)\S{7,}$")
_SECRET_PATH_HINT = (
"Path must start with '/', contain no '://', and be at least 8 characters."
)
def _is_valid_secret_path(path: str) -> bool:
"""Return True if path starts with '/', contains no '://', and is at least 8 characters."""
return bool(_SECRET_PATH_RE.match(path))
def get_or_create_secret_path(data_dir: Path, custom_path: str = "") -> str:
"""Get existing secret path or create a new one.
Args:
data_dir: Path to the /data directory
custom_path: Optional custom path from config (overrides auto-generated)
Returns:
The secret path to use
"""
secret_file = data_dir / "secret_path.txt"
# If custom path is provided, use it and update the stored path
if custom_path and custom_path.strip():
path = custom_path.strip()
if not path.startswith("/"):
path = "/" + path
if not _is_valid_secret_path(path):
log_error(
f"Custom secret path is invalid ({path!r}), ignoring. {_SECRET_PATH_HINT}"
)
else:
log_info("Using custom secret path from configuration")
# Update stored path for consistency
secret_file.write_text(path)
return path
# Check if we have a stored secret path
if secret_file.exists():
try:
stored_path = secret_file.read_text().strip()
if _is_valid_secret_path(stored_path):
log_info("Using existing auto-generated secret path")
return stored_path
elif stored_path:
log_error(
f"Stored secret path is invalid ({stored_path!r}), regenerating. {_SECRET_PATH_HINT}"
)
else:
log_error("Stored secret path is empty, regenerating")
except Exception as e:
log_error(f"Failed to read stored secret path: {e}")
# Generate new secret path
new_path = generate_secret_path()
log_info("Generated new secret path with 128-bit entropy")
try:
data_dir.mkdir(parents=True, exist_ok=True)
secret_file.write_text(new_path)
return new_path
except Exception as e:
log_error(f"Failed to save secret path: {e}")
# Return the path anyway - it will work for this session
return new_path
def persist_addon_options(options: dict[str, Any], supervisor_token: str) -> None:
"""POST the full addon options dict to the Supervisor.
The endpoint is a full-replace validated against the addon schema, so
callers must pass the complete options dict (not a partial patch).
Used after auto-generating the secret path so other addons (the
webhook proxy) can read it from `GET /addons/{slug}/info → options`
instead of scraping it from addon logs (#941).
Raises the underlying `urllib.error.HTTPError` / `URLError` / `OSError`
on failure — callers decide how loudly to surface the problem.
"""
payload = json.dumps({"options": options}).encode()
req = urllib.request.Request(
"http://supervisor/addons/self/options",
data=payload,
method="POST",
headers={
"Authorization": f"Bearer {supervisor_token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
def maybe_persist_secret_path(
config: dict[str, Any], secret_path: str, supervisor_token: str
) -> None:
"""Persist `secret_path` into the addon's stored options when needed.
Only calls `persist_addon_options` when all of these hold:
- `config` is non-empty. If `/data/options.json` was missing or failed
to parse, `config` is `{}` and the addon is running off hardcoded
defaults. Sending a bare `{"secret_path": ...}` in that state would
be rejected by Supervisor's schema validation (missing required
`backup_hint`), producing a second misleading error line on top of
the "Failed to read config" we already logged.
- The resolved `secret_path` differs from the stored one. Otherwise
the write is a pure no-op and we'd just add noise on every restart.
Errors from the POST are caught and logged with an actionable recovery
message — the addon keeps running, but the user is told exactly which
value to paste into the Configuration tab if they hit it.
"""
if not config:
return
if secret_path == config.get("secret_path", ""):
return
try:
persist_addon_options({**config, "secret_path": secret_path}, supervisor_token)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError) as e:
detail = (
f"HTTP {e.code}: {e.reason}"
if isinstance(e, urllib.error.HTTPError)
else str(e)
)
log_error(
f"Failed to persist secret_path to addon options ({detail}). "
f"This addon will still run with secret_path={secret_path!r}, "
"but other addons (e.g. the webhook proxy) cannot auto-discover "
"it via Supervisor. Workaround: open this addon's Configuration "
"tab and paste the secret_path above into the 'Secret path override' "
"field, then save."
)
def resolve_bool_option(config: dict[str, Any], key: str, default: bool) -> bool:
"""Read ``key`` from ``config`` as a bool, falling back to ``default``.
Mirrors the ``raw = config.get(key, default); raw if isinstance(raw, bool) else default``
pattern used inline in ``main()`` for other options. Extracted so the
verify_ssl plumbing can be unit-tested without standing up the full
addon container.
"""
raw = config.get(key, default)
return raw if isinstance(raw, bool) else default
_STALE_MIGRATION_MARKER = ".skills_as_tools_default_migration_v1"
def cleanup_stale_migration_marker(data_dir: Path) -> None:
"""Remove the one-time enable_skills_as_tools migration marker.
The marker was created by the previous version's
``migrate_skills_as_tools_default`` (removed in #1133). It is now
unused on every install; cleaning it up prevents permanent ``/data``
litter for users who upgraded across the toggle removal. ``unlink``
is best-effort — a stale dotfile is harmless if removal fails.
"""
marker = data_dir / _STALE_MIGRATION_MARKER
try:
marker.unlink(missing_ok=True)
except OSError as e:
log_error(
f"Failed to remove stale migration marker {marker}: {e}. "
"Safe to ignore — the file is unused."
)
def main() -> int:
"""Start the Home Assistant MCP Server."""
log_info("Starting Home Assistant MCP Server...")
# Read configuration from Supervisor
config_file = Path("/data/options.json")
data_dir = Path("/data")
cleanup_stale_migration_marker(data_dir)
config: dict[str, Any] = {}
backup_hint = "normal" # default
custom_secret_path = "" # default
enable_tool_search = False # default
enable_tool_security_policies = False # default
enable_yaml_config_editing = False # default
enable_filesystem_tools = False # default
enable_custom_component_integration = False # default
enable_code_mode = False # default
enable_lite_docstrings = False # default
enable_auto_backup = (
True # default (#1288 — on by default; opt out via ENABLE_AUTO_BACKUP=false)
)
auto_backup_throttle_minutes = 0 # default — every write
auto_backup_retain_per_entity = 100 # default
tool_search_max_results = 5 # default
disabled_tools_raw = "" # default
pinned_tools_raw = "" # default
verify_ssl = True # default
advanced_debug_logging = False # default
if config_file.exists():
try:
with open(config_file) as f:
config = json.load(f)
backup_hint = config.get("backup_hint", "normal")
custom_secret_path = config.get("secret_path", "")
raw_tool_search = config.get("enable_tool_search", False)
enable_tool_search = (
raw_tool_search if isinstance(raw_tool_search, bool) else False
)
raw_tool_security_policies = config.get(
"enable_tool_security_policies", False
)
enable_tool_security_policies = (
raw_tool_security_policies
if isinstance(raw_tool_security_policies, bool)
else False
)
raw_yaml_config = config.get("enable_yaml_config_editing", False)
enable_yaml_config_editing = (
raw_yaml_config if isinstance(raw_yaml_config, bool) else False
)
raw_filesystem_tools = config.get("enable_filesystem_tools", False)
enable_filesystem_tools = (
raw_filesystem_tools
if isinstance(raw_filesystem_tools, bool)
else False
)
raw_custom_component = config.get(
"enable_custom_component_integration", False
)
enable_custom_component_integration = (
raw_custom_component
if isinstance(raw_custom_component, bool)
else False
)
raw_code_mode = config.get("enable_code_mode", False)
enable_code_mode = (
raw_code_mode if isinstance(raw_code_mode, bool) else False
)
raw_lite_docstrings = config.get("enable_lite_docstrings", False)
enable_lite_docstrings = (
raw_lite_docstrings if isinstance(raw_lite_docstrings, bool) else False
)
raw_auto_backup = config.get("enable_auto_backup", True)
enable_auto_backup = (
raw_auto_backup if isinstance(raw_auto_backup, bool) else False
)
raw_throttle = config.get("auto_backup_throttle_minutes", 0)
auto_backup_throttle_minutes = (
raw_throttle if isinstance(raw_throttle, int) else 0
)
raw_retain = config.get("auto_backup_retain_per_entity", 100)
auto_backup_retain_per_entity = (
raw_retain if isinstance(raw_retain, int) else 100
)
raw_max_results = config.get("tool_search_max_results", 5)
tool_search_max_results = (
raw_max_results if isinstance(raw_max_results, int) else 5
)
raw_disabled = config.get("disabled_tools", "")
disabled_tools_raw = raw_disabled if isinstance(raw_disabled, str) else ""
raw_pinned = config.get("pinned_tools", "")
pinned_tools_raw = raw_pinned if isinstance(raw_pinned, str) else ""
verify_ssl = resolve_bool_option(config, "verify_ssl", True)
advanced_debug_logging = resolve_bool_option(
config, "advanced_debug_logging", False
)
except Exception as e:
log_error(f"Failed to read config: {e}, using defaults")
# Validate Supervisor token (needed for both ha-mcp auth below and the
# options-persist call right after secret path resolution)
supervisor_token = os.environ.get("SUPERVISOR_TOKEN")
if not supervisor_token:
log_error("SUPERVISOR_TOKEN not found! Cannot authenticate.")
return 1
# Generate or retrieve secret path
secret_path = get_or_create_secret_path(data_dir, custom_secret_path)
# Persist secret path back to addon options so other addons (e.g. the
# webhook proxy) can read it via `GET /addons/{slug}/info → options`
# instead of scraping it from this addon's logs (#941). Details and
# the skip/retry rules live in maybe_persist_secret_path().
maybe_persist_secret_path(config, secret_path, supervisor_token)
log_info(f"Backup hint mode: {backup_hint}")
log_info(f"Verify SSL: {verify_ssl}")
log_info(f"Advanced debug logging: {advanced_debug_logging}")
# Set up environment for ha-mcp
os.environ["HOMEASSISTANT_URL"] = "http://supervisor/core"
os.environ["BACKUP_HINT"] = backup_hint
os.environ["ENABLE_TOOL_SEARCH"] = str(enable_tool_search).lower()
os.environ["ENABLE_TOOL_SECURITY_POLICIES"] = str(
enable_tool_security_policies
).lower()
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
os.environ["HAMCP_ENABLE_FILESYSTEM_TOOLS"] = str(enable_filesystem_tools).lower()
os.environ["HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"] = str(
enable_custom_component_integration
).lower()
os.environ["ENABLE_CODE_MODE"] = str(enable_code_mode).lower()
os.environ["ENABLE_LITE_DOCSTRINGS"] = str(enable_lite_docstrings).lower()
os.environ["ENABLE_AUTO_BACKUP"] = str(enable_auto_backup).lower()
os.environ["AUTO_BACKUP_THROTTLE_MINUTES"] = str(auto_backup_throttle_minutes)
os.environ["AUTO_BACKUP_RETAIN_PER_ENTITY"] = str(auto_backup_retain_per_entity)
# Persist saved custom tools across addon restarts. /data is the
# per-addon writable directory mapped by Supervisor and survives
# add-on updates (but not uninstall/reinstall — users should copy
# this file out before reinstalling if they want to migrate).
# Setting this unconditionally is safe: on the stable add-on the
# tool isn't registered anyway, so the file is never read or
# written. Operators can override by setting CODE_MODE_SAVED_TOOLS_PATH
# in the add-on's environment if they want a different location.
os.environ.setdefault("CODE_MODE_SAVED_TOOLS_PATH", "/data/saved_tools.json")
os.environ["TOOL_SEARCH_MAX_RESULTS"] = str(tool_search_max_results)
os.environ["DISABLED_TOOLS"] = disabled_tools_raw
os.environ["PINNED_TOOLS"] = pinned_tools_raw
os.environ["HA_VERIFY_SSL"] = str(verify_ssl).lower()
os.environ["HOMEASSISTANT_TOKEN"] = supervisor_token
log_info(f"Home Assistant URL: {os.environ['HOMEASSISTANT_URL']}")
log_info("Authentication configured via Supervisor token")
# Fixed port (internal container port)
port = 9583
log_info("")
log_info("=" * 80)
log_info(f"🔐 MCP Server URL: http://<home-assistant-ip>:9583{secret_path}")
log_info("")
log_info(f" Secret Path: {secret_path}")
log_info("")
log_info(" ⚠️ IMPORTANT: Copy this exact URL - the secret path is required!")
log_info(" 💡 This path is auto-generated and persisted to /data/secret_path.txt")
log_info("=" * 80)
log_info("")
# Configure logging before server start (v3 removed log_level from run())
import logging
logging.basicConfig(level=logging.INFO)
# Import and register browser landing before server start
log_info("Importing ha_mcp module...")
from ha_mcp.__main__ import (
StatelessSessionLogFilter,
_get_server,
_get_timestamped_uvicorn_log_config,
mcp,
register_browser_landing,
)
from ha_mcp.settings_ui import register_settings_routes
if advanced_debug_logging:
# Defers SA_SIGINFO install until uvicorn's capture_signals has
# run. Otherwise uvicorn's signal.signal() call would overwrite
# our handler before any signal arrived.
# Wrapped because diagnostics must never block addon startup.
try:
from ha_mcp.utils.kill_signal_diagnostics import (
schedule_install_after_uvicorn,
)
schedule_install_after_uvicorn()
except Exception as e:
log_error(f"advanced_debug_logging install failed: {e!r}; continuing")
register_browser_landing(mcp, secret_path)
# Mount settings UI routes both at root (for HA ingress proxy) and
# under the secret path (for direct port access). See
# register_settings_routes docstring for the auth model. Use the
# server's actual FastMCP instance (not the _DeferredMCP wrapper)
# so mypy doesn't trip over the duck-typed __getattr__ forwarding.
server_instance = _get_server()
register_settings_routes(
server_instance.mcp, server_instance, secret_path=secret_path
)
logging.getLogger("mcp.server.streamable_http").addFilter(
StatelessSessionLogFilter()
)
try:
log_info("Starting MCP server...")
mcp.run(
transport="http",
host="0.0.0.0",
port=port,
path=secret_path,
stateless_http=True,
uvicorn_config={"log_config": _get_timestamped_uvicorn_log_config()},
)
except KeyboardInterrupt:
log_info("Interrupted, exiting")
return 0
except BaseException as e:
import traceback
log_error(f"MCP server crashed: {e}")
traceback.print_exc(file=sys.stderr)
# Log the root cause if this exception was chained
cause = e.__cause__ or e.__context__
if cause:
log_error(f"Caused by: {cause}")
traceback.print_exception(
type(cause), cause, cause.__traceback__, file=sys.stderr
)
if isinstance(e, SystemExit):
return int(e.code) if isinstance(e.code, int) else 1
return 1
log_info("MCP server stopped")
return 0
if __name__ == "__main__":
sys.exit(main())