@@ -65,13 +65,15 @@ def _handle_config_error(error: Exception) -> None:
6565 missing_vars .append (f" - { field_loc [0 ]} " )
6666
6767 if missing_vars :
68- print (_CONFIG_ERROR_MESSAGE .format (
69- missing_vars = "\n " .join (missing_vars )
70- ), file = sys .stderr )
68+ print (
69+ _CONFIG_ERROR_MESSAGE .format (missing_vars = "\n " .join (missing_vars )),
70+ file = sys .stderr ,
71+ )
7172 sys .exit (1 )
7273
7374 # For other validation errors, show the original error with guidance
74- print (f"""
75+ print (
76+ f"""
7577==============================================================================
7678 Home Assistant MCP Server - Configuration Error
7779==============================================================================
@@ -82,18 +84,22 @@ def _handle_config_error(error: Exception) -> None:
8284 https://github.com/homeassistant-ai/ha-mcp#-installation
8385
8486==============================================================================
85- """ , file = sys .stderr )
87+ """ ,
88+ file = sys .stderr ,
89+ )
8690 sys .exit (1 )
8791
8892
8993def _create_server ():
9094 """Create server instance (deferred to avoid import during smoke test)."""
9195 try :
9296 from ha_mcp .server import HomeAssistantSmartMCPServer # type: ignore[import-not-found]
97+
9398 return HomeAssistantSmartMCPServer ()
9499 except Exception as e :
95100 # Check if this is a pydantic validation error (missing env vars)
96101 from pydantic import ValidationError
102+
97103 if isinstance (e , ValidationError ):
98104 _handle_config_error (e )
99105 raise
@@ -123,6 +129,7 @@ def _get_server():
123129# This is accessed when the module is imported, so we need deferred creation
124130class _DeferredMCP :
125131 """Wrapper that defers MCP creation until actually accessed."""
132+
126133 def __getattr__ (self , name : str ) -> Any :
127134 return getattr (_get_mcp (), name )
128135
@@ -142,6 +149,7 @@ async def _cleanup_resources() -> None:
142149 # Close WebSocket listener service if running
143150 try :
144151 from ha_mcp .client .websocket_listener import stop_websocket_listener
152+
145153 await stop_websocket_listener ()
146154 logger .debug ("WebSocket listener stopped" )
147155 except Exception as e :
@@ -150,6 +158,7 @@ async def _cleanup_resources() -> None:
150158 # Close WebSocket manager connections
151159 try :
152160 from ha_mcp .client .websocket_client import websocket_manager
161+
153162 await websocket_manager .disconnect ()
154163 logger .debug ("WebSocket manager disconnected" )
155164 except Exception as e :
@@ -241,8 +250,7 @@ async def _run_with_graceful_shutdown() -> None:
241250 # Clean up resources with timeout
242251 try :
243252 await asyncio .wait_for (
244- _cleanup_resources (),
245- timeout = SHUTDOWN_TIMEOUT_SECONDS
253+ _cleanup_resources (), timeout = SHUTDOWN_TIMEOUT_SECONDS
246254 )
247255 except TimeoutError :
248256 logger .warning ("Resource cleanup timed out" )
@@ -269,6 +277,7 @@ def main() -> None:
269277 # Check for smoke test flag
270278 if "--smoke-test" in sys .argv :
271279 from ha_mcp .smoke_test import main as smoke_test_main
280+
272281 sys .exit (smoke_test_main ())
273282
274283 # Configure logging before server creation
@@ -363,8 +372,7 @@ async def _run_http_with_graceful_shutdown(
363372 # Clean up resources with timeout
364373 try :
365374 await asyncio .wait_for (
366- _cleanup_resources (),
367- timeout = SHUTDOWN_TIMEOUT_SECONDS
375+ _cleanup_resources (), timeout = SHUTDOWN_TIMEOUT_SECONDS
368376 )
369377 except TimeoutError :
370378 logger .warning ("Resource cleanup timed out" )
@@ -452,5 +460,127 @@ def main_sse() -> None:
452460 _run_http_server ("sse" , default_port = 8087 )
453461
454462
463+ def main_oauth () -> None :
464+ """Run server with OAuth 2.1 authentication over HTTP.
465+
466+ This mode enables zero-config authentication for MCP clients like Claude.ai.
467+ Users authenticate via a consent form where they enter their Home Assistant
468+ URL and Long-Lived Access Token.
469+
470+ Environment:
471+ - MCP_PORT (optional, default: 8086)
472+ - MCP_SECRET_PATH (optional, default: "/mcp")
473+ - MCP_BASE_URL (optional, default: http://localhost:{MCP_PORT})
474+
475+ Note: HOMEASSISTANT_URL and HOMEASSISTANT_TOKEN are NOT required in this mode.
476+ They are collected via the OAuth consent form.
477+ """
478+ port = int (os .getenv ("MCP_PORT" , "8086" ))
479+ path = os .getenv ("MCP_SECRET_PATH" , "/mcp" )
480+ base_url = os .getenv ("MCP_BASE_URL" , f"http://localhost:{ port } " )
481+
482+ # Set up signal handlers
483+ _setup_signal_handlers ()
484+
485+ try :
486+ asyncio .run (_run_oauth_server (base_url , port , path ))
487+ except KeyboardInterrupt :
488+ logger .info ("Interrupted, exiting" )
489+ except SystemExit :
490+ raise
491+ except Exception as e :
492+ logger .error (f"OAuth server error: { e } " )
493+ sys .exit (1 )
494+
495+ sys .exit (0 )
496+
497+
498+ async def _run_oauth_server (base_url : str , port : int , path : str ) -> None :
499+ """Run the OAuth-authenticated MCP server."""
500+ global _shutdown_event
501+
502+ from fastmcp import FastMCP
503+ from ha_mcp .auth import HomeAssistantOAuthProvider
504+
505+ _shutdown_event = asyncio .Event ()
506+
507+ # Create OAuth provider
508+ auth_provider = HomeAssistantOAuthProvider (
509+ base_url = base_url ,
510+ service_documentation_url = "https://github.com/homeassistant-ai/ha-mcp" ,
511+ )
512+
513+ # Create a minimal FastMCP server with OAuth
514+ # Note: In OAuth mode, we create a simpler server that doesn't require
515+ # pre-configured HA credentials. The tools will use credentials from OAuth.
516+ mcp = FastMCP (
517+ name = "ha-mcp" ,
518+ version = "4.14.0" ,
519+ auth = auth_provider ,
520+ )
521+
522+ # Register a simple tool to verify authentication works
523+ @mcp .tool
524+ async def get_ha_connection_info () -> dict :
525+ """Get information about the authenticated Home Assistant connection."""
526+ from fastmcp .server .dependencies import get_access_token
527+
528+ token = get_access_token ()
529+ if not token :
530+ return {"error" : "Not authenticated" }
531+
532+ # Get HA credentials from the OAuth provider
533+ credentials = auth_provider .get_ha_credentials_for_token (token .token )
534+ if not credentials :
535+ return {"error" : "No Home Assistant credentials found for this session" }
536+
537+ return {
538+ "ha_url" : credentials .ha_url ,
539+ "authenticated" : True ,
540+ "validated_at" : credentials .validated_at ,
541+ }
542+
543+ logger .info (f"Starting OAuth-enabled MCP server on { base_url } { path } " )
544+
545+ # Run server
546+ server_task = asyncio .create_task (
547+ mcp .run_async (
548+ transport = "streamable-http" ,
549+ host = "0.0.0.0" ,
550+ port = port ,
551+ path = path ,
552+ )
553+ )
554+
555+ shutdown_task = asyncio .create_task (_shutdown_event .wait ())
556+
557+ try :
558+ done , pending = await asyncio .wait (
559+ [server_task , shutdown_task ],
560+ return_when = asyncio .FIRST_COMPLETED ,
561+ )
562+
563+ if shutdown_task in done :
564+ logger .info ("Shutdown signal received, stopping OAuth server..." )
565+ server_task .cancel ()
566+ try :
567+ await asyncio .wait_for (server_task , timeout = SHUTDOWN_TIMEOUT_SECONDS )
568+ except TimeoutError :
569+ logger .warning ("OAuth server did not stop within timeout" )
570+ except asyncio .CancelledError :
571+ pass
572+
573+ except asyncio .CancelledError :
574+ logger .info ("OAuth server task cancelled" )
575+ finally :
576+ for task in [server_task , shutdown_task ]:
577+ if not task .done ():
578+ task .cancel ()
579+ try :
580+ await task
581+ except asyncio .CancelledError :
582+ pass
583+
584+
455585if __name__ == "__main__" :
456586 main ()
0 commit comments