Skip to content

Commit aae7acb

Browse files
julienldclaude
andcommitted
feat: add OAuth 2.1 authentication with DCR and consent form
Implements OAuth 2.1 authentication for ha-mcp using FastMCP's OAuthProvider pattern with Dynamic Client Registration (DCR). This enables secure, zero-config authentication for MCP clients like Claude.ai. Features: - HomeAssistantOAuthProvider subclass with full OAuth 2.1 support - Consent form for collecting Home Assistant URL and Long-Lived Access Token - LLAT validation against HA API before issuing auth codes - Token management with refresh token support - New `ha-mcp-oauth` entry point for OAuth-enabled server mode The OAuth flow: 1. Claude.ai registers dynamically via DCR 2. User is redirected to consent form 3. User enters HA URL and LLAT 4. Credentials are validated against HA 5. Auth code is issued and exchanged for tokens Closes #245 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7751dd4 commit aae7acb

8 files changed

Lines changed: 1776 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ ha-mcp-smoke-test = "ha_mcp.smoke_test:main"
4646
hamcp-test-env = "tests.test_env_manager:main"
4747
ha-mcp-web = "ha_mcp.__main__:main_web"
4848
ha-mcp-sse = "ha_mcp.__main__:main_sse"
49+
ha-mcp-oauth = "ha_mcp.__main__:main_oauth"
4950

5051
[tool.setuptools]
5152
package-dir = {"" = "src", "tests" = "tests"}

src/ha_mcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
__author__ = "Julien"
1010
__license__ = "MIT"
1111

12+
from .auth import HomeAssistantOAuthProvider
1213
from .client.rest_client import HomeAssistantClient
1314
from .config import Settings
1415
from .errors import (
@@ -32,6 +33,7 @@
3233
"Settings",
3334
"HomeAssistantClient",
3435
"HomeAssistantSmartMCPServer",
36+
"HomeAssistantOAuthProvider",
3537
# Error handling exports
3638
"ErrorCode",
3739
"create_error_response",

src/ha_mcp/__main__.py

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

8993
def _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
124130
class _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+
455585
if __name__ == "__main__":
456586
main()

src/ha_mcp/auth/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
OAuth 2.1 authentication for Home Assistant MCP Server.
3+
4+
This module provides OAuth 2.1 authentication with Dynamic Client Registration (DCR)
5+
and a consent form for collecting Home Assistant credentials.
6+
"""
7+
8+
from .provider import HomeAssistantOAuthProvider
9+
10+
__all__ = ["HomeAssistantOAuthProvider"]

0 commit comments

Comments
 (0)