ha-mcp-dev 6.3.1.dev137__py3-none-any.whl
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/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
ha_mcp/__main__.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"""Home Assistant MCP Server."""
|
|
2
|
+
|
|
3
|
+
import truststore
|
|
4
|
+
truststore.inject_into_ssl()
|
|
5
|
+
|
|
6
|
+
import asyncio # noqa: E402
|
|
7
|
+
import logging # noqa: E402
|
|
8
|
+
import os # noqa: E402
|
|
9
|
+
import signal # noqa: E402
|
|
10
|
+
import stat # noqa: E402
|
|
11
|
+
import sys # noqa: E402
|
|
12
|
+
from typing import Any # noqa: E402
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OAuthProxyClient:
|
|
18
|
+
"""Proxy client that dynamically forwards to the correct OAuth-authenticated client.
|
|
19
|
+
|
|
20
|
+
This class is necessary because tools capture a reference to the client at registration time.
|
|
21
|
+
The proxy allows us to inject different credentials per-request based on OAuth token claims.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, auth_provider):
|
|
25
|
+
self._auth_provider = auth_provider
|
|
26
|
+
self._oauth_clients = {}
|
|
27
|
+
|
|
28
|
+
def _get_oauth_client(self):
|
|
29
|
+
"""Get the OAuth client for the current request context."""
|
|
30
|
+
from fastmcp.server.dependencies import get_access_token
|
|
31
|
+
from ha_mcp.client.rest_client import HomeAssistantClient
|
|
32
|
+
|
|
33
|
+
# Get the access token from the current request context
|
|
34
|
+
token = get_access_token()
|
|
35
|
+
|
|
36
|
+
if not token:
|
|
37
|
+
logger.warning("No access token in context")
|
|
38
|
+
raise RuntimeError("No OAuth token in request context")
|
|
39
|
+
|
|
40
|
+
# Extract HA credentials from token claims
|
|
41
|
+
claims = token.claims
|
|
42
|
+
|
|
43
|
+
if not claims or "ha_url" not in claims or "ha_token" not in claims:
|
|
44
|
+
logger.error(f"No HA credentials in token claims: {claims}")
|
|
45
|
+
raise RuntimeError("No Home Assistant credentials in OAuth token claims")
|
|
46
|
+
|
|
47
|
+
ha_url = claims["ha_url"]
|
|
48
|
+
ha_token = claims["ha_token"]
|
|
49
|
+
|
|
50
|
+
# Create or reuse client for these credentials
|
|
51
|
+
client_key = f"{ha_url}:{ha_token}"
|
|
52
|
+
if client_key not in self._oauth_clients:
|
|
53
|
+
self._oauth_clients[client_key] = HomeAssistantClient(
|
|
54
|
+
base_url=ha_url,
|
|
55
|
+
token=ha_token,
|
|
56
|
+
)
|
|
57
|
+
logger.info(f"Created OAuth client for {ha_url}")
|
|
58
|
+
|
|
59
|
+
return self._oauth_clients[client_key]
|
|
60
|
+
|
|
61
|
+
def __getattr__(self, name):
|
|
62
|
+
"""Forward all attribute access to the OAuth client."""
|
|
63
|
+
client = self._get_oauth_client()
|
|
64
|
+
return getattr(client, name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Shutdown configuration
|
|
68
|
+
SHUTDOWN_TIMEOUT_SECONDS = 2.0
|
|
69
|
+
|
|
70
|
+
# Global shutdown state
|
|
71
|
+
_shutdown_event: asyncio.Event | None = None
|
|
72
|
+
_shutdown_in_progress = False
|
|
73
|
+
|
|
74
|
+
# Stdin error message for Docker without -i flag
|
|
75
|
+
_STDIN_ERROR_MESSAGE = """
|
|
76
|
+
==============================================================================
|
|
77
|
+
Home Assistant MCP Server - Stdin Not Available
|
|
78
|
+
==============================================================================
|
|
79
|
+
|
|
80
|
+
The MCP server requires an interactive stdin for stdio transport mode.
|
|
81
|
+
|
|
82
|
+
This typically happens when running Docker without the -i flag:
|
|
83
|
+
docker run ghcr.io/homeassistant-ai/ha-mcp:latest # stdin is closed
|
|
84
|
+
|
|
85
|
+
To fix this, use one of the following options:
|
|
86
|
+
|
|
87
|
+
1. Add the -i flag to enable interactive stdin:
|
|
88
|
+
docker run -i -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\
|
|
89
|
+
ghcr.io/homeassistant-ai/ha-mcp:latest
|
|
90
|
+
|
|
91
|
+
2. Use HTTP mode instead (recommended for servers/automation):
|
|
92
|
+
docker run -d -p 8086:8086 -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\
|
|
93
|
+
ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web
|
|
94
|
+
|
|
95
|
+
For more information, see:
|
|
96
|
+
https://github.com/homeassistant-ai/ha-mcp#-docker
|
|
97
|
+
|
|
98
|
+
==============================================================================
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# Configuration error message template
|
|
102
|
+
_CONFIG_ERROR_MESSAGE = """
|
|
103
|
+
==============================================================================
|
|
104
|
+
Home Assistant MCP Server - Configuration Error
|
|
105
|
+
==============================================================================
|
|
106
|
+
|
|
107
|
+
Missing required environment variables:
|
|
108
|
+
{missing_vars}
|
|
109
|
+
|
|
110
|
+
To fix this, you need to provide your Home Assistant connection details:
|
|
111
|
+
|
|
112
|
+
1. HOMEASSISTANT_URL - Your Home Assistant instance URL
|
|
113
|
+
Example: http://homeassistant.local:8123
|
|
114
|
+
|
|
115
|
+
2. HOMEASSISTANT_TOKEN - A long-lived access token
|
|
116
|
+
Get one from: Home Assistant -> Profile -> Long-Lived Access Tokens
|
|
117
|
+
|
|
118
|
+
Configuration options:
|
|
119
|
+
- Set environment variables directly:
|
|
120
|
+
export HOMEASSISTANT_URL=http://homeassistant.local:8123
|
|
121
|
+
export HOMEASSISTANT_TOKEN=your_token_here
|
|
122
|
+
|
|
123
|
+
- Or create a .env file in the project directory (copy from .env.example)
|
|
124
|
+
|
|
125
|
+
For detailed setup instructions, see:
|
|
126
|
+
https://github.com/homeassistant-ai/ha-mcp#-installation
|
|
127
|
+
|
|
128
|
+
==============================================================================
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_stdin_available() -> bool:
|
|
133
|
+
"""Check if stdin is available for reading.
|
|
134
|
+
|
|
135
|
+
Returns True if stdin is usable (terminal, pipe, or file).
|
|
136
|
+
Returns False if stdin is closed or not readable (e.g., Docker without -i).
|
|
137
|
+
|
|
138
|
+
When Docker runs without the -i flag, stdin is connected to /dev/null,
|
|
139
|
+
which immediately returns EOF. This causes the stdio transport to exit.
|
|
140
|
+
"""
|
|
141
|
+
# Check if stdin is closed
|
|
142
|
+
if sys.stdin is None or sys.stdin.closed:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
fd = sys.stdin.fileno()
|
|
147
|
+
mode = os.fstat(fd).st_mode
|
|
148
|
+
except (ValueError, OSError):
|
|
149
|
+
# fileno() or fstat() can raise if stdin is not a real file
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# Allow TTYs, pipes (how MCP clients communicate), and regular files (testing)
|
|
153
|
+
if os.isatty(fd) or stat.S_ISFIFO(mode) or stat.S_ISREG(mode):
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
# Block character devices that aren't TTYs (like /dev/null in Docker without -i)
|
|
157
|
+
if stat.S_ISCHR(mode):
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
# Unknown type - allow it and let the server handle any issues
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _handle_config_error(error: Exception) -> None:
|
|
165
|
+
"""Handle configuration errors with a user-friendly message."""
|
|
166
|
+
from pydantic import ValidationError
|
|
167
|
+
|
|
168
|
+
if isinstance(error, ValidationError):
|
|
169
|
+
# Extract missing field names from pydantic errors
|
|
170
|
+
missing_vars = []
|
|
171
|
+
for err in error.errors():
|
|
172
|
+
if err.get("type") == "missing":
|
|
173
|
+
# The field name is the alias (env var name)
|
|
174
|
+
field_loc = err.get("loc", ())
|
|
175
|
+
if field_loc:
|
|
176
|
+
missing_vars.append(f" - {field_loc[0]}")
|
|
177
|
+
|
|
178
|
+
if missing_vars:
|
|
179
|
+
print(
|
|
180
|
+
_CONFIG_ERROR_MESSAGE.format(missing_vars="\n".join(missing_vars)),
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
# For other validation errors, show the original error with guidance
|
|
186
|
+
print(
|
|
187
|
+
f"""
|
|
188
|
+
==============================================================================
|
|
189
|
+
Home Assistant MCP Server - Configuration Error
|
|
190
|
+
==============================================================================
|
|
191
|
+
|
|
192
|
+
{error}
|
|
193
|
+
|
|
194
|
+
For setup instructions, see:
|
|
195
|
+
https://github.com/homeassistant-ai/ha-mcp#-installation
|
|
196
|
+
|
|
197
|
+
==============================================================================
|
|
198
|
+
""",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _create_server():
|
|
205
|
+
"""Create server instance (deferred to avoid import during smoke test)."""
|
|
206
|
+
try:
|
|
207
|
+
from ha_mcp.server import HomeAssistantSmartMCPServer # type: ignore[import-not-found]
|
|
208
|
+
|
|
209
|
+
return HomeAssistantSmartMCPServer()
|
|
210
|
+
except Exception as e:
|
|
211
|
+
# Check if this is a pydantic validation error (missing env vars)
|
|
212
|
+
from pydantic import ValidationError
|
|
213
|
+
|
|
214
|
+
if isinstance(e, ValidationError):
|
|
215
|
+
_handle_config_error(e)
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Lazy server creation - only create when needed
|
|
220
|
+
_server = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_mcp():
|
|
224
|
+
"""Get the MCP instance, creating server if needed."""
|
|
225
|
+
global _server
|
|
226
|
+
if _server is None:
|
|
227
|
+
_server = _create_server()
|
|
228
|
+
return _server.mcp
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _get_server():
|
|
232
|
+
"""Get the server instance, creating if needed."""
|
|
233
|
+
global _server
|
|
234
|
+
if _server is None:
|
|
235
|
+
_server = _create_server()
|
|
236
|
+
return _server
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# For module-level access (e.g., fastmcp.json referencing ha_mcp.__main__:mcp)
|
|
240
|
+
# This is accessed when the module is imported, so we need deferred creation
|
|
241
|
+
class _DeferredMCP:
|
|
242
|
+
"""Wrapper that defers MCP creation until actually accessed."""
|
|
243
|
+
|
|
244
|
+
def __getattr__(self, name: str) -> Any:
|
|
245
|
+
return getattr(_get_mcp(), name)
|
|
246
|
+
|
|
247
|
+
def run(self, *args: Any, **kwargs: Any) -> None:
|
|
248
|
+
return _get_mcp().run(*args, **kwargs)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
mcp = _DeferredMCP()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _cleanup_resources() -> None:
|
|
255
|
+
"""Clean up all server resources gracefully."""
|
|
256
|
+
global _server
|
|
257
|
+
|
|
258
|
+
logger.info("Cleaning up server resources...")
|
|
259
|
+
|
|
260
|
+
# Close WebSocket listener service if running
|
|
261
|
+
try:
|
|
262
|
+
from ha_mcp.client.websocket_listener import stop_websocket_listener
|
|
263
|
+
|
|
264
|
+
await stop_websocket_listener()
|
|
265
|
+
logger.debug("WebSocket listener stopped")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.debug(f"WebSocket listener cleanup: {e}")
|
|
268
|
+
|
|
269
|
+
# Close WebSocket manager connections
|
|
270
|
+
try:
|
|
271
|
+
from ha_mcp.client.websocket_client import websocket_manager
|
|
272
|
+
|
|
273
|
+
await websocket_manager.disconnect()
|
|
274
|
+
logger.debug("WebSocket manager disconnected")
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.debug(f"WebSocket manager cleanup: {e}")
|
|
277
|
+
|
|
278
|
+
# Close the server's HTTP client
|
|
279
|
+
if _server is not None:
|
|
280
|
+
try:
|
|
281
|
+
await _server.close()
|
|
282
|
+
logger.debug("Server closed")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.debug(f"Server cleanup: {e}")
|
|
285
|
+
|
|
286
|
+
logger.info("Server resources cleaned up")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _signal_handler(signum: int, frame: Any) -> None:
|
|
290
|
+
"""Handle shutdown signals (SIGTERM, SIGINT).
|
|
291
|
+
|
|
292
|
+
This handler initiates graceful shutdown on first signal.
|
|
293
|
+
On second signal, forces immediate exit.
|
|
294
|
+
"""
|
|
295
|
+
global _shutdown_in_progress, _shutdown_event
|
|
296
|
+
|
|
297
|
+
sig_name = signal.Signals(signum).name
|
|
298
|
+
|
|
299
|
+
if _shutdown_in_progress:
|
|
300
|
+
# Second signal - force exit
|
|
301
|
+
logger.warning(f"Received {sig_name} again, forcing exit")
|
|
302
|
+
sys.exit(1)
|
|
303
|
+
|
|
304
|
+
_shutdown_in_progress = True
|
|
305
|
+
logger.info(f"Received {sig_name}, initiating graceful shutdown...")
|
|
306
|
+
|
|
307
|
+
# Signal the shutdown event if we have an event loop
|
|
308
|
+
if _shutdown_event is not None:
|
|
309
|
+
try:
|
|
310
|
+
loop = asyncio.get_running_loop()
|
|
311
|
+
loop.call_soon_threadsafe(_shutdown_event.set)
|
|
312
|
+
except RuntimeError:
|
|
313
|
+
# No running event loop, just exit
|
|
314
|
+
sys.exit(0)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _setup_signal_handlers() -> None:
|
|
318
|
+
"""Set up signal handlers for graceful shutdown."""
|
|
319
|
+
# Register signal handlers
|
|
320
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
321
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def _run_with_graceful_shutdown() -> None:
|
|
325
|
+
"""Run the MCP server with graceful shutdown support."""
|
|
326
|
+
global _shutdown_event
|
|
327
|
+
|
|
328
|
+
_shutdown_event = asyncio.Event()
|
|
329
|
+
|
|
330
|
+
# Respect FastMCP's show_cli_banner setting
|
|
331
|
+
# Users can disable banner via FASTMCP_SHOW_CLI_BANNER=false
|
|
332
|
+
import fastmcp
|
|
333
|
+
show_banner = fastmcp.settings.show_cli_banner
|
|
334
|
+
|
|
335
|
+
# Create a task for the MCP server
|
|
336
|
+
server_task = asyncio.create_task(_get_mcp().run_async(show_banner=show_banner))
|
|
337
|
+
|
|
338
|
+
# Wait for either the server to complete or a shutdown signal
|
|
339
|
+
shutdown_task = asyncio.create_task(_shutdown_event.wait())
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
done, pending = await asyncio.wait(
|
|
343
|
+
[server_task, shutdown_task],
|
|
344
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# If shutdown was signaled, cancel the server task
|
|
348
|
+
if shutdown_task in done:
|
|
349
|
+
logger.info("Shutdown signal received, stopping server...")
|
|
350
|
+
server_task.cancel()
|
|
351
|
+
try:
|
|
352
|
+
await asyncio.wait_for(server_task, timeout=SHUTDOWN_TIMEOUT_SECONDS)
|
|
353
|
+
except TimeoutError:
|
|
354
|
+
logger.warning("Server did not stop within timeout")
|
|
355
|
+
except asyncio.CancelledError:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
except asyncio.CancelledError:
|
|
359
|
+
logger.info("Server task cancelled")
|
|
360
|
+
finally:
|
|
361
|
+
# Clean up resources with timeout
|
|
362
|
+
try:
|
|
363
|
+
await asyncio.wait_for(
|
|
364
|
+
_cleanup_resources(), timeout=SHUTDOWN_TIMEOUT_SECONDS
|
|
365
|
+
)
|
|
366
|
+
except TimeoutError:
|
|
367
|
+
logger.warning("Resource cleanup timed out")
|
|
368
|
+
|
|
369
|
+
# Cancel any remaining tasks
|
|
370
|
+
for task in [server_task, shutdown_task]:
|
|
371
|
+
if not task.done():
|
|
372
|
+
task.cancel()
|
|
373
|
+
try:
|
|
374
|
+
await task
|
|
375
|
+
except asyncio.CancelledError:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# CLI entry point (for pyproject.toml) - use FastMCP's built-in runner
|
|
380
|
+
def main() -> None:
|
|
381
|
+
"""Run server via CLI using FastMCP's stdio transport."""
|
|
382
|
+
# Handle --version flag early, before server creation requires config
|
|
383
|
+
if "--version" in sys.argv or "-V" in sys.argv:
|
|
384
|
+
from importlib.metadata import version
|
|
385
|
+
print(f"ha-mcp {version('ha-mcp')}")
|
|
386
|
+
sys.exit(0)
|
|
387
|
+
|
|
388
|
+
# Check for smoke test flag
|
|
389
|
+
if "--smoke-test" in sys.argv:
|
|
390
|
+
from ha_mcp.smoke_test import main as smoke_test_main
|
|
391
|
+
|
|
392
|
+
sys.exit(smoke_test_main())
|
|
393
|
+
|
|
394
|
+
# Configure logging before server creation
|
|
395
|
+
from ha_mcp.config import get_settings
|
|
396
|
+
settings = get_settings()
|
|
397
|
+
|
|
398
|
+
# In standard mode (not OAuth), validate that real credentials are provided
|
|
399
|
+
# The config has defaults for OAuth mode, but standard mode requires real values
|
|
400
|
+
# Check config FIRST so users see helpful config errors before stdin errors
|
|
401
|
+
missing_vars = []
|
|
402
|
+
if settings.homeassistant_url == "http://oauth-mode":
|
|
403
|
+
missing_vars.append(" - HOMEASSISTANT_URL")
|
|
404
|
+
if settings.homeassistant_token == "oauth-mode-token":
|
|
405
|
+
missing_vars.append(" - HOMEASSISTANT_TOKEN")
|
|
406
|
+
|
|
407
|
+
if missing_vars:
|
|
408
|
+
print(
|
|
409
|
+
_CONFIG_ERROR_MESSAGE.format(missing_vars="\n".join(missing_vars)),
|
|
410
|
+
file=sys.stderr,
|
|
411
|
+
)
|
|
412
|
+
sys.exit(1)
|
|
413
|
+
|
|
414
|
+
# Check if stdin is available (fails in Docker without -i flag)
|
|
415
|
+
# This check comes after config validation so users see config errors first
|
|
416
|
+
if not _check_stdin_available():
|
|
417
|
+
print(_STDIN_ERROR_MESSAGE, file=sys.stderr)
|
|
418
|
+
sys.exit(1)
|
|
419
|
+
|
|
420
|
+
logging.basicConfig(
|
|
421
|
+
level=getattr(logging, settings.log_level),
|
|
422
|
+
format='%(asctime)s %(name)s %(levelname)s: %(message)s'
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Set up signal handlers before running
|
|
426
|
+
_setup_signal_handlers()
|
|
427
|
+
|
|
428
|
+
# Run with graceful shutdown support
|
|
429
|
+
try:
|
|
430
|
+
asyncio.run(_run_with_graceful_shutdown())
|
|
431
|
+
except KeyboardInterrupt:
|
|
432
|
+
# Handle case where KeyboardInterrupt is raised before our handler
|
|
433
|
+
logger.info("Interrupted, exiting")
|
|
434
|
+
except SystemExit:
|
|
435
|
+
raise
|
|
436
|
+
except Exception as e:
|
|
437
|
+
logger.error(f"Server error: {e}")
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
sys.exit(0)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# HTTP entry point for web clients
|
|
444
|
+
def _get_http_runtime(default_port: int = 8086) -> tuple[int, str]:
|
|
445
|
+
"""Return runtime configuration shared by HTTP transports.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
default_port: Default port to use if MCP_PORT env var is not set.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
port = int(os.getenv("MCP_PORT", str(default_port)))
|
|
452
|
+
path = os.getenv("MCP_SECRET_PATH", "/mcp")
|
|
453
|
+
return port, path
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def _run_http_with_graceful_shutdown(
|
|
457
|
+
transport: str,
|
|
458
|
+
host: str,
|
|
459
|
+
port: int,
|
|
460
|
+
path: str,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Run HTTP server with graceful shutdown support."""
|
|
463
|
+
global _shutdown_event
|
|
464
|
+
|
|
465
|
+
_shutdown_event = asyncio.Event()
|
|
466
|
+
|
|
467
|
+
# Respect FastMCP's show_cli_banner setting
|
|
468
|
+
# Users can disable banner via FASTMCP_SHOW_CLI_BANNER=false
|
|
469
|
+
import fastmcp
|
|
470
|
+
show_banner = fastmcp.settings.show_cli_banner
|
|
471
|
+
|
|
472
|
+
# Create a task for the MCP server
|
|
473
|
+
server_task = asyncio.create_task(
|
|
474
|
+
_get_mcp().run_async(
|
|
475
|
+
transport=transport,
|
|
476
|
+
host=host,
|
|
477
|
+
port=port,
|
|
478
|
+
path=path,
|
|
479
|
+
show_banner=show_banner,
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Wait for either the server to complete or a shutdown signal
|
|
484
|
+
shutdown_task = asyncio.create_task(_shutdown_event.wait())
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
done, pending = await asyncio.wait(
|
|
488
|
+
[server_task, shutdown_task],
|
|
489
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# If shutdown was signaled, cancel the server task
|
|
493
|
+
if shutdown_task in done:
|
|
494
|
+
logger.info("Shutdown signal received, stopping HTTP server...")
|
|
495
|
+
server_task.cancel()
|
|
496
|
+
try:
|
|
497
|
+
await asyncio.wait_for(server_task, timeout=SHUTDOWN_TIMEOUT_SECONDS)
|
|
498
|
+
except TimeoutError:
|
|
499
|
+
logger.warning("HTTP server did not stop within timeout")
|
|
500
|
+
except asyncio.CancelledError:
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
except asyncio.CancelledError:
|
|
504
|
+
logger.info("HTTP server task cancelled")
|
|
505
|
+
finally:
|
|
506
|
+
# Clean up resources with timeout
|
|
507
|
+
try:
|
|
508
|
+
await asyncio.wait_for(
|
|
509
|
+
_cleanup_resources(), timeout=SHUTDOWN_TIMEOUT_SECONDS
|
|
510
|
+
)
|
|
511
|
+
except TimeoutError:
|
|
512
|
+
logger.warning("Resource cleanup timed out")
|
|
513
|
+
|
|
514
|
+
# Cancel any remaining tasks
|
|
515
|
+
for task in [server_task, shutdown_task]:
|
|
516
|
+
if not task.done():
|
|
517
|
+
task.cancel()
|
|
518
|
+
try:
|
|
519
|
+
await task
|
|
520
|
+
except asyncio.CancelledError:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _run_http_server(transport: str, default_port: int = 8086) -> None:
|
|
525
|
+
"""Common runner for HTTP-based transports.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
transport: Transport type (streamable-http or sse).
|
|
529
|
+
default_port: Default port to use if MCP_PORT env var is not set.
|
|
530
|
+
"""
|
|
531
|
+
port, path = _get_http_runtime(default_port)
|
|
532
|
+
|
|
533
|
+
# Set up signal handlers before running
|
|
534
|
+
_setup_signal_handlers()
|
|
535
|
+
|
|
536
|
+
# Run with graceful shutdown support
|
|
537
|
+
try:
|
|
538
|
+
asyncio.run(
|
|
539
|
+
_run_http_with_graceful_shutdown(
|
|
540
|
+
transport=transport,
|
|
541
|
+
host="0.0.0.0",
|
|
542
|
+
port=port,
|
|
543
|
+
path=path,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
except KeyboardInterrupt:
|
|
547
|
+
logger.info("Interrupted, exiting")
|
|
548
|
+
except SystemExit:
|
|
549
|
+
raise
|
|
550
|
+
except Exception as e:
|
|
551
|
+
logger.error(f"HTTP server error: {e}")
|
|
552
|
+
sys.exit(1)
|
|
553
|
+
|
|
554
|
+
sys.exit(0)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def main_web() -> None:
|
|
558
|
+
"""Run server over HTTP for web-capable MCP clients.
|
|
559
|
+
|
|
560
|
+
Environment:
|
|
561
|
+
- HOMEASSISTANT_URL (required)
|
|
562
|
+
- HOMEASSISTANT_TOKEN (required)
|
|
563
|
+
- MCP_PORT (optional, default: 8086)
|
|
564
|
+
- MCP_SECRET_PATH (optional, default: "/mcp")
|
|
565
|
+
"""
|
|
566
|
+
# Configure logging before server creation
|
|
567
|
+
from ha_mcp.config import get_settings
|
|
568
|
+
settings = get_settings()
|
|
569
|
+
|
|
570
|
+
# Validate credentials (required in non-OAuth HTTP mode)
|
|
571
|
+
missing_vars = []
|
|
572
|
+
if settings.homeassistant_url == "http://oauth-mode":
|
|
573
|
+
missing_vars.append(" - HOMEASSISTANT_URL")
|
|
574
|
+
if settings.homeassistant_token == "oauth-mode-token":
|
|
575
|
+
missing_vars.append(" - HOMEASSISTANT_TOKEN")
|
|
576
|
+
|
|
577
|
+
if missing_vars:
|
|
578
|
+
print(
|
|
579
|
+
_CONFIG_ERROR_MESSAGE.format(missing_vars="\n".join(missing_vars)),
|
|
580
|
+
file=sys.stderr,
|
|
581
|
+
)
|
|
582
|
+
sys.exit(1)
|
|
583
|
+
|
|
584
|
+
logging.basicConfig(
|
|
585
|
+
level=getattr(logging, settings.log_level),
|
|
586
|
+
format='%(asctime)s %(name)s %(levelname)s: %(message)s'
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
_run_http_server("streamable-http", default_port=8086)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def main_sse() -> None:
|
|
593
|
+
"""Run server using Server-Sent Events transport for MCP clients.
|
|
594
|
+
|
|
595
|
+
Environment:
|
|
596
|
+
- HOMEASSISTANT_URL (required)
|
|
597
|
+
- HOMEASSISTANT_TOKEN (required)
|
|
598
|
+
- MCP_PORT (optional, default: 8087)
|
|
599
|
+
- MCP_SECRET_PATH (optional, default: "/mcp")
|
|
600
|
+
"""
|
|
601
|
+
# Configure logging before server creation
|
|
602
|
+
from ha_mcp.config import get_settings
|
|
603
|
+
settings = get_settings()
|
|
604
|
+
|
|
605
|
+
# Validate credentials (required in non-OAuth SSE mode)
|
|
606
|
+
missing_vars = []
|
|
607
|
+
if settings.homeassistant_url == "http://oauth-mode":
|
|
608
|
+
missing_vars.append(" - HOMEASSISTANT_URL")
|
|
609
|
+
if settings.homeassistant_token == "oauth-mode-token":
|
|
610
|
+
missing_vars.append(" - HOMEASSISTANT_TOKEN")
|
|
611
|
+
|
|
612
|
+
if missing_vars:
|
|
613
|
+
print(
|
|
614
|
+
_CONFIG_ERROR_MESSAGE.format(missing_vars="\n".join(missing_vars)),
|
|
615
|
+
file=sys.stderr,
|
|
616
|
+
)
|
|
617
|
+
sys.exit(1)
|
|
618
|
+
|
|
619
|
+
logging.basicConfig(
|
|
620
|
+
level=getattr(logging, settings.log_level),
|
|
621
|
+
format='%(asctime)s %(name)s %(levelname)s: %(message)s'
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
_run_http_server("sse", default_port=8087)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def main_oauth() -> None:
|
|
628
|
+
"""Run server with OAuth 2.1 authentication over HTTP.
|
|
629
|
+
|
|
630
|
+
This mode enables zero-config authentication for MCP clients like Claude.ai.
|
|
631
|
+
Users authenticate via a consent form where they enter their Home Assistant
|
|
632
|
+
URL and Long-Lived Access Token.
|
|
633
|
+
|
|
634
|
+
Environment:
|
|
635
|
+
- MCP_PORT (optional, default: 8086)
|
|
636
|
+
- MCP_SECRET_PATH (optional, default: "/mcp")
|
|
637
|
+
- MCP_BASE_URL (optional, default: http://localhost:{MCP_PORT})
|
|
638
|
+
- LOG_LEVEL (optional, default: INFO)
|
|
639
|
+
|
|
640
|
+
Note: HOMEASSISTANT_URL and HOMEASSISTANT_TOKEN are NOT required in this mode.
|
|
641
|
+
They are collected via the OAuth consent form.
|
|
642
|
+
"""
|
|
643
|
+
# Configure logging for OAuth mode
|
|
644
|
+
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
645
|
+
logging.basicConfig(
|
|
646
|
+
level=getattr(logging, log_level),
|
|
647
|
+
format='%(asctime)s %(name)s %(levelname)s: %(message)s',
|
|
648
|
+
force=True # Force reconfiguration
|
|
649
|
+
)
|
|
650
|
+
# Also configure all ha_mcp loggers
|
|
651
|
+
for logger_name in ['ha_mcp', 'ha_mcp.auth', 'ha_mcp.auth.provider']:
|
|
652
|
+
logging.getLogger(logger_name).setLevel(getattr(logging, log_level))
|
|
653
|
+
logger.info(f"OAuth mode logging configured at {log_level} level")
|
|
654
|
+
|
|
655
|
+
port = int(os.getenv("MCP_PORT", "8086"))
|
|
656
|
+
path = os.getenv("MCP_SECRET_PATH", "/mcp")
|
|
657
|
+
base_url = os.getenv("MCP_BASE_URL", f"http://localhost:{port}")
|
|
658
|
+
|
|
659
|
+
# Set up signal handlers
|
|
660
|
+
_setup_signal_handlers()
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
asyncio.run(_run_oauth_server(base_url, port, path))
|
|
664
|
+
except KeyboardInterrupt:
|
|
665
|
+
logger.info("Interrupted, exiting")
|
|
666
|
+
except SystemExit:
|
|
667
|
+
raise
|
|
668
|
+
except Exception as e:
|
|
669
|
+
logger.error(f"OAuth server error: {e}")
|
|
670
|
+
sys.exit(1)
|
|
671
|
+
|
|
672
|
+
sys.exit(0)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
async def _run_oauth_server(base_url: str, port: int, path: str) -> None:
|
|
676
|
+
"""Run the OAuth-authenticated MCP server."""
|
|
677
|
+
global _shutdown_event
|
|
678
|
+
|
|
679
|
+
from ha_mcp.auth import HomeAssistantOAuthProvider
|
|
680
|
+
from ha_mcp.server import HomeAssistantSmartMCPServer
|
|
681
|
+
|
|
682
|
+
_shutdown_event = asyncio.Event()
|
|
683
|
+
|
|
684
|
+
# Create OAuth provider
|
|
685
|
+
auth_provider = HomeAssistantOAuthProvider(
|
|
686
|
+
base_url=base_url,
|
|
687
|
+
service_documentation_url="https://github.com/homeassistant-ai/ha-mcp",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Create full HomeAssistantSmartMCPServer with OAuth authentication
|
|
691
|
+
# In OAuth mode, we don't require pre-configured HA credentials in environment.
|
|
692
|
+
# Instead, tools will get credentials from the OAuth provider per-request.
|
|
693
|
+
# The Settings class now has defaults that work for OAuth mode.
|
|
694
|
+
|
|
695
|
+
# Create proxy client that dynamically forwards to OAuth clients
|
|
696
|
+
# This is necessary because tools capture a reference to the client at registration time.
|
|
697
|
+
proxy_client = OAuthProxyClient(auth_provider)
|
|
698
|
+
|
|
699
|
+
# Create server with the proxy client
|
|
700
|
+
server = HomeAssistantSmartMCPServer(client=proxy_client)
|
|
701
|
+
mcp = server.mcp
|
|
702
|
+
|
|
703
|
+
logger.info("Server created with OAuthProxyClient")
|
|
704
|
+
|
|
705
|
+
# Add OAuth authentication to the MCP server
|
|
706
|
+
mcp.auth = auth_provider
|
|
707
|
+
|
|
708
|
+
# Get tool count (get_tools is async, but we can count registered tools)
|
|
709
|
+
tools = await mcp.get_tools()
|
|
710
|
+
logger.info(f"Starting OAuth-enabled MCP server with {len(tools)} tools on {base_url}{path}")
|
|
711
|
+
|
|
712
|
+
# Run server
|
|
713
|
+
server_task = asyncio.create_task(
|
|
714
|
+
mcp.run_async(
|
|
715
|
+
transport="streamable-http",
|
|
716
|
+
host="0.0.0.0",
|
|
717
|
+
port=port,
|
|
718
|
+
path=path,
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
shutdown_task = asyncio.create_task(_shutdown_event.wait())
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
done, pending = await asyncio.wait(
|
|
726
|
+
[server_task, shutdown_task],
|
|
727
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
if shutdown_task in done:
|
|
731
|
+
logger.info("Shutdown signal received, stopping OAuth server...")
|
|
732
|
+
server_task.cancel()
|
|
733
|
+
try:
|
|
734
|
+
await asyncio.wait_for(server_task, timeout=SHUTDOWN_TIMEOUT_SECONDS)
|
|
735
|
+
except TimeoutError:
|
|
736
|
+
logger.warning("OAuth server did not stop within timeout")
|
|
737
|
+
except asyncio.CancelledError:
|
|
738
|
+
pass
|
|
739
|
+
|
|
740
|
+
except asyncio.CancelledError:
|
|
741
|
+
logger.info("OAuth server task cancelled")
|
|
742
|
+
finally:
|
|
743
|
+
for task in [server_task, shutdown_task]:
|
|
744
|
+
if not task.done():
|
|
745
|
+
task.cancel()
|
|
746
|
+
try:
|
|
747
|
+
await task
|
|
748
|
+
except asyncio.CancelledError:
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
if __name__ == "__main__":
|
|
753
|
+
main()
|