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.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. 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()