# Directory Structure ``` ├── .gitignore ├── .python-version ├── app │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── home_assistant.py │ ├── config.py │ ├── tools │ │ ├── __init__.py │ │ └── device_controls.py │ └── utils │ ├── __init__.py │ └── search.py ├── LICENSE ├── main.py ├── pyproject.toml ├── README.md └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.11 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Home Assistant MCP 2 | 3 | A Model Context Protocol (MCP) integration for controlling Home Assistant devices using AI assistants. 4 | 5 | ## Overview 6 | 7 | This MCP allows AI assistants to control your Home Assistant devices. It provides tools to: 8 | 9 | 1. Search for entities in your Home Assistant instance 10 | 2. Control devices (turn them on/off) 11 | 3. Control light colors and brightness 12 | 13 | ## Prerequisites 14 | 15 | - Python 3.11 or higher 16 | - Home Assistant instance running and accessible via API 17 | - Home Assistant Long-Lived Access Token 18 | 19 | ## Installation 20 | 21 | 1. Clone this repository 22 | 2. Set up a Python environment: 23 | 24 | ```bash 25 | cd home-assistant 26 | python -m venv .venv 27 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 28 | pip install -U pip 29 | pip install uv 30 | uv pip install -e . 31 | ``` 32 | 33 | ## Configuration 34 | 35 | ### Get a Home Assistant Long-Lived Access Token 36 | 37 | 1. Go to your Home Assistant instance 38 | 2. Navigate to your profile (click on your username in the sidebar) 39 | 3. Scroll down to "Long-Lived Access Tokens" 40 | 4. Create a new token with a descriptive name like "MCP Integration" 41 | 5. Copy the token (you'll only see it once) 42 | 43 | ### Set up in Cursor AI 44 | 45 | Add the following configuration to your MCP configuration in Cursor: 46 | 47 | ```json 48 | { 49 | "mcpServers": { 50 | "home_assistant": { 51 | "command": "uv", 52 | "args": [ 53 | "--directory", 54 | "/path/to/your/home-assistant-mcp", 55 | "run", 56 | "main.py" 57 | ], 58 | "env": { 59 | "HOME_ASSISTANT_TOKEN": "your_home_assistant_token_here" 60 | }, 61 | "inheritEnv": true 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | Replace: 68 | 69 | - `/path/to/your/home-assistant` with the actual path to this directory 70 | - `your_home_assistant_token_here` with your Home Assistant Long-Lived Access Token 71 | 72 | ### Home Assistant URL Configuration 73 | 74 | By default, the MCP tries to connect to Home Assistant at `http://homeassistant.local:8123`. 75 | 76 | If your Home Assistant is at a different URL, you can modify the `HA_URL` variable in `app/config.py`. 77 | 78 | ## Usage 79 | 80 | Once configured, you can use Cursor AI to control your Home Assistant devices: 81 | 82 | - Search for devices: "Find my living room lights" 83 | - Control devices: "Turn on the kitchen light" 84 | - Control light colors: "Set my living room lights to red" 85 | - Adjust brightness: "Set my dining room lights to blue at 50% brightness" 86 | 87 | ### Light Control Features 88 | 89 | The MCP now supports advanced light control capabilities: 90 | 91 | 1. **Color Control**: Set any RGB color for compatible lights 92 | - Specify colors using RGB values (0-255 for each component) 93 | - Example: `set_device_color("light.living_room", 255, 0, 0)` for red 94 | 95 | 2. **Brightness Control**: Adjust light brightness 96 | - Optional brightness parameter (0-255) 97 | - Can be combined with color changes 98 | - Example: `set_device_color("light.dining_room", 0, 0, 255, brightness=128)` for medium-bright blue 99 | 100 | ## Troubleshooting 101 | 102 | - If you get authentication errors, verify your token is correct and has not expired 103 | - Check that your Home Assistant instance is reachable at the configured URL 104 | - For color control issues: 105 | - Verify that your light entity supports RGB color control 106 | - Check that the light is turned on before attempting to change colors 107 | 108 | ## Future Capabilities 109 | 110 | ### Dynamic Entity Exposure 111 | 112 | The current implementation requires a two-step process to control devices: 113 | 114 | 1. Search for entities using natural language 115 | 2. Control the entity using its specific entity_id 116 | 117 | A planned enhancement is to create a more dynamic way to expose entities to the control devices tool, allowing the AI to: 118 | 119 | - Directly control devices through more natural commands (e.g., "turn off the kitchen lights") 120 | - Cache frequently used entities for faster access 121 | - Support more complex operations like adjusting brightness, temperature, or other attributes 122 | - Handle entity groups and scenes more intuitively 123 | 124 | This would significantly reduce the time to action and create a more seamless user experience when controlling Home Assistant devices through an AI assistant. ``` -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- ```python 1 | from .home_assistant import make_ha_request, get_all_entities 2 | 3 | __all__ = ['make_ha_request', 'get_all_entities'] 4 | ``` -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | from .search import search_entities_by_keywords, format_entity_results 2 | 3 | __all__ = ['search_entities_by_keywords', 'format_entity_results'] 4 | ``` -------------------------------------------------------------------------------- /app/tools/__init__.py: -------------------------------------------------------------------------------- ```python 1 | from .device_controls import init_tools, control_device, search_entities, set_device_color 2 | 3 | __all__ = ['init_tools', 'control_device', 'search_entities', 'set_device_color'] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "home-assistant" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "mcp[cli]>=1.4.1", 10 | ] 11 | ``` -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | 3 | # Home Assistant API configuration 4 | HA_URL = "http://homeassistant.local:8123" 5 | HOME_ASSISTANT_TOKEN = os.environ.get("HOME_ASSISTANT_TOKEN") 6 | USER_AGENT = "home-assistant-mcp/1.0" 7 | 8 | def validate_config(): 9 | """Validate that the required configuration is present.""" 10 | if not HOME_ASSISTANT_TOKEN: 11 | raise ValueError("HOME_ASSISTANT_TOKEN environment variable is not set") 12 | return True ``` -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- ```python 1 | from .config import validate_config 2 | from .api import make_ha_request, get_all_entities 3 | from .tools import init_tools, control_device, search_entities, set_device_color 4 | from .utils import search_entities_by_keywords, format_entity_results 5 | 6 | __all__ = [ 7 | 'validate_config', 8 | 'make_ha_request', 9 | 'get_all_entities', 10 | 'init_tools', 11 | 'control_device', 12 | 'search_entities', 13 | 'set_device_color', 14 | 'search_entities_by_keywords', 15 | 'format_entity_results' 16 | ] ``` -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | from app.config import validate_config 3 | from app.tools import init_tools 4 | 5 | # Initialize FastMCP server 6 | mcp = FastMCP("home-assistant") 7 | 8 | # Initialize the tools with the FastMCP instance 9 | init_tools(mcp) 10 | 11 | if __name__ == "__main__": 12 | # Validate configuration 13 | try: 14 | validate_config() 15 | except ValueError as e: 16 | print(f"Configuration error: {str(e)}") 17 | exit(1) 18 | 19 | # Initialize and run the server using stdio transport 20 | mcp.run(transport='stdio') ``` -------------------------------------------------------------------------------- /app/utils/search.py: -------------------------------------------------------------------------------- ```python 1 | import re 2 | from typing import List, Dict, Any 3 | 4 | def search_entities_by_keywords(entities: List[Dict[str, Any]], description: str) -> List[Dict[str, Any]]: 5 | """Search for entities matching a natural language description. 6 | 7 | Args: 8 | entities: List of entity dictionaries from Home Assistant 9 | description: Natural language description of the entity 10 | 11 | Returns: 12 | A list of matching entities sorted by relevance score 13 | """ 14 | # Break description into keywords 15 | keywords = re.findall(r'\w+', description.lower()) 16 | 17 | # Search for matching entities 18 | matches = [] 19 | for entity in entities: 20 | entity_id = entity.get("entity_id", "").lower() 21 | friendly_name = entity.get("attributes", {}).get("friendly_name", "").lower() 22 | 23 | # Check if any keyword matches the entity_id or friendly_name 24 | score = 0 25 | for keyword in keywords: 26 | if keyword in entity_id or keyword in friendly_name: 27 | score += 1 28 | 29 | if score > 0: 30 | matches.append({ 31 | "entity_id": entity.get("entity_id"), 32 | "friendly_name": entity.get("attributes", {}).get("friendly_name", ""), 33 | "score": score 34 | }) 35 | 36 | # Sort matches by score (descending) 37 | return sorted(matches, key=lambda x: x["score"], reverse=True) 38 | 39 | def format_entity_results(matches: List[Dict[str, Any]], limit: int = 5) -> str: 40 | """Format entity search results into a readable string. 41 | 42 | Args: 43 | matches: List of matching entities with scores 44 | limit: Maximum number of results to include 45 | 46 | Returns: 47 | Formatted string with entity results 48 | """ 49 | if matches: 50 | result = "Found matching entities:\n" 51 | for match in matches[:limit]: 52 | result += f"- {match['entity_id']} ({match['friendly_name']})\n" 53 | return result 54 | else: 55 | return "No matching entities found." ``` -------------------------------------------------------------------------------- /app/api/home_assistant.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Dict, Any, List, Optional 2 | import httpx 3 | from ..config import HA_URL, HOME_ASSISTANT_TOKEN, USER_AGENT 4 | 5 | async def make_ha_request(domain: str, service: str, entity_id: str, additional_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 6 | """Make a request to the Home Assistant API with proper error handling.""" 7 | url = f"{HA_URL}/api/services/{domain}/{service}" 8 | 9 | headers = { 10 | "Authorization": f"Bearer {HOME_ASSISTANT_TOKEN}", 11 | "Content-Type": "application/json", 12 | "User-Agent": USER_AGENT 13 | } 14 | payload = {"entity_id": entity_id} 15 | if additional_data: 16 | payload.update(additional_data) 17 | 18 | async with httpx.AsyncClient() as client: 19 | try: 20 | response = await client.post(url, headers=headers, json=payload, timeout=10.0) 21 | response.raise_for_status() 22 | return {"success": True, "status_code": response.status_code} 23 | except Exception as e: 24 | return {"success": False, "error": str(e)} 25 | 26 | async def set_light_color(entity_id: str, rgb_color: List[int], brightness: Optional[int] = None) -> Dict[str, Any]: 27 | """Set the color and optionally brightness of a light entity. 28 | 29 | Args: 30 | entity_id: The entity ID of the light 31 | rgb_color: List of 3 integers [r, g, b] each between 0 and 255 32 | brightness: Optional brightness level between 0 and 255 33 | """ 34 | additional_data = { 35 | "rgb_color": rgb_color 36 | } 37 | if brightness is not None: 38 | additional_data["brightness"] = brightness 39 | 40 | return await make_ha_request("light", "turn_on", entity_id, additional_data) 41 | 42 | async def get_all_entities() -> List[Dict[str, Any]]: 43 | """Fetch all entities from Home Assistant.""" 44 | url = f"{HA_URL}/api/states" 45 | 46 | headers = { 47 | "Authorization": f"Bearer {HOME_ASSISTANT_TOKEN}", 48 | "Content-Type": "application/json", 49 | "User-Agent": USER_AGENT 50 | } 51 | 52 | async with httpx.AsyncClient() as client: 53 | try: 54 | response = await client.get(url, headers=headers, timeout=10.0) 55 | response.raise_for_status() 56 | return response.json() 57 | except Exception as e: 58 | return [] ``` -------------------------------------------------------------------------------- /app/tools/device_controls.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Dict, Any, List 2 | from mcp.server.fastmcp import FastMCP 3 | from ..api.home_assistant import make_ha_request, get_all_entities, set_light_color 4 | from ..config import HOME_ASSISTANT_TOKEN 5 | from ..utils.search import search_entities_by_keywords, format_entity_results 6 | 7 | # Tools need to be registered with a FastMCP instance 8 | # This will be initialized in the main.py file and passed here 9 | mcp = None 10 | 11 | def init_tools(fastmcp_instance: FastMCP): 12 | """Initialize the tools with a FastMCP instance.""" 13 | global mcp 14 | mcp = fastmcp_instance 15 | 16 | # Register tools with the FastMCP instance 17 | fastmcp_instance.tool()(control_device) 18 | fastmcp_instance.tool()(search_entities) 19 | fastmcp_instance.tool()(set_device_color) # Register set_device_color like other tools 20 | 21 | async def control_device(entity_id: str, state: str) -> str: 22 | """Control a Home Assistant entity by turning it on or off. 23 | 24 | Args: 25 | entity_id: The Home Assistant entity ID to control (format: domain.entity) 26 | state: The desired state ('on' or 'off') 27 | """ 28 | # Basic validation 29 | if not entity_id or "." not in entity_id: 30 | return f"Invalid entity ID format: {entity_id}. Must be in format: domain.entity" 31 | 32 | state = state.lower() 33 | if state not in ["on", "off"]: 34 | return f"Invalid state: {state}. Must be 'on' or 'off'" 35 | 36 | # Check token 37 | if not HOME_ASSISTANT_TOKEN: 38 | return "Home Assistant token not configured. Set HOME_ASSISTANT_TOKEN environment variable." 39 | 40 | # Get domain from entity_id 41 | domain = entity_id.split(".")[0] 42 | service = "turn_on" if state == "on" else "turn_off" 43 | 44 | # Call the HA API 45 | result = await make_ha_request(domain, service, entity_id) 46 | 47 | if result["success"]: 48 | return f"Successfully turned {state} {entity_id}" 49 | else: 50 | return f"Failed to control {entity_id}: {result.get('error', 'Unknown error')}" 51 | 52 | async def set_device_color(entity_id: str, red: int, green: int, blue: int, brightness: int = None) -> str: 53 | """Set the color and optionally brightness of a light entity. 54 | 55 | Args: 56 | entity_id: The Home Assistant entity ID to control (format: light.entity) 57 | red: Red component (0-255) 58 | green: Green component (0-255) 59 | blue: Blue component (0-255) 60 | brightness: Optional brightness level (0-255) 61 | """ 62 | # Basic validation 63 | if not entity_id or not entity_id.startswith("light."): 64 | return f"Invalid entity ID format: {entity_id}. Must be a light entity (format: light.entity)" 65 | 66 | # Validate RGB values 67 | for color, name in [(red, "Red"), (green, "Green"), (blue, "Blue")]: 68 | if not 0 <= color <= 255: 69 | return f"Invalid {name} value: {color}. Must be between 0 and 255" 70 | 71 | # Validate brightness if provided 72 | if brightness is not None and not 0 <= brightness <= 255: 73 | return f"Invalid brightness value: {brightness}. Must be between 0 and 255" 74 | 75 | # Check token 76 | if not HOME_ASSISTANT_TOKEN: 77 | return "Home Assistant token not configured. Set HOME_ASSISTANT_TOKEN environment variable." 78 | 79 | # Call the HA API 80 | result = await set_light_color(entity_id, [red, green, blue], brightness) 81 | 82 | if result["success"]: 83 | color_msg = f"color to RGB({red},{green},{blue})" 84 | brightness_msg = f" and brightness to {brightness}" if brightness is not None else "" 85 | return f"Successfully set {entity_id} {color_msg}{brightness_msg}" 86 | else: 87 | return f"Failed to set color for {entity_id}: {result.get('error', 'Unknown error')}" 88 | 89 | async def search_entities(description: str) -> str: 90 | """Search for Home Assistant entities matching a natural language description. 91 | 92 | Args: 93 | description: Natural language description of the entity (e.g., "office light", "kitchen fan") 94 | 95 | Returns: 96 | A list of matching entity IDs with their friendly names, or an error message 97 | """ 98 | # Check token 99 | if not HOME_ASSISTANT_TOKEN: 100 | return "Home Assistant token not configured. Set HOME_ASSISTANT_TOKEN environment variable." 101 | 102 | # Get all entities 103 | entities = await get_all_entities() 104 | if not entities: 105 | return "Failed to retrieve entities from Home Assistant." 106 | 107 | # Search and format results 108 | matches = search_entities_by_keywords(entities, description) 109 | return format_entity_results(matches) ```