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
·231 lines (190 loc) · 8.63 KB
/
Copy pathstart.py
File metadata and controls
executable file
·231 lines (190 loc) · 8.63 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
#!/usr/bin/env python3
"""Home Assistant MCP Server Add-on startup script."""
import json
import os
import re
import secrets
import sys
from datetime import datetime
from pathlib import Path
from typing import 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 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")
backup_hint = "normal" # default
custom_secret_path = "" # default
enable_skills = True # default
enable_skills_as_tools = False # default
enable_tool_search = False # default
enable_yaml_config_editing = False # default
tool_search_max_results = 5 # default
disabled_tools_raw = "" # default
pinned_tools_raw = "" # 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_skills = config.get("enable_skills", True)
enable_skills = raw_skills if isinstance(raw_skills, bool) else True
raw_skills_as_tools = config.get("enable_skills_as_tools", False)
enable_skills_as_tools = raw_skills_as_tools if isinstance(raw_skills_as_tools, bool) else False
raw_tool_search = config.get("enable_tool_search", False)
enable_tool_search = raw_tool_search if isinstance(raw_tool_search, 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_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 ""
except Exception as e:
log_error(f"Failed to read config: {e}, using defaults")
# Generate or retrieve secret path
secret_path = get_or_create_secret_path(data_dir, custom_secret_path)
log_info(f"Backup hint mode: {backup_hint}")
# Set up environment for ha-mcp
os.environ["HOMEASSISTANT_URL"] = "http://supervisor/core"
os.environ["BACKUP_HINT"] = backup_hint
os.environ["ENABLE_SKILLS"] = str(enable_skills).lower()
os.environ["ENABLE_SKILLS_AS_TOOLS"] = str(enable_skills_as_tools).lower()
os.environ["ENABLE_TOOL_SEARCH"] = str(enable_tool_search).lower()
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
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
# Validate Supervisor token
supervisor_token = os.environ.get("SUPERVISOR_TOKEN")
if not supervisor_token:
log_error("SUPERVISOR_TOKEN not found! Cannot authenticate.")
return 1
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_timestamped_uvicorn_log_config,
mcp,
register_browser_landing,
)
register_browser_landing(mcp, 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())