# 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: -------------------------------------------------------------------------------- ``` 3.11 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Python-generated files __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info # Virtual environments .venv ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Home Assistant MCP A Model Context Protocol (MCP) integration for controlling Home Assistant devices using AI assistants. ## Overview This MCP allows AI assistants to control your Home Assistant devices. It provides tools to: 1. Search for entities in your Home Assistant instance 2. Control devices (turn them on/off) 3. Control light colors and brightness ## Prerequisites - Python 3.11 or higher - Home Assistant instance running and accessible via API - Home Assistant Long-Lived Access Token ## Installation 1. Clone this repository 2. Set up a Python environment: ```bash cd home-assistant python -m venv .venv source .venv/bin/activate # On Windows: .venv\Scripts\activate pip install -U pip pip install uv uv pip install -e . ``` ## Configuration ### Get a Home Assistant Long-Lived Access Token 1. Go to your Home Assistant instance 2. Navigate to your profile (click on your username in the sidebar) 3. Scroll down to "Long-Lived Access Tokens" 4. Create a new token with a descriptive name like "MCP Integration" 5. Copy the token (you'll only see it once) ### Set up in Cursor AI Add the following configuration to your MCP configuration in Cursor: ```json { "mcpServers": { "home_assistant": { "command": "uv", "args": [ "--directory", "/path/to/your/home-assistant-mcp", "run", "main.py" ], "env": { "HOME_ASSISTANT_TOKEN": "your_home_assistant_token_here" }, "inheritEnv": true } } } ``` Replace: - `/path/to/your/home-assistant` with the actual path to this directory - `your_home_assistant_token_here` with your Home Assistant Long-Lived Access Token ### Home Assistant URL Configuration By default, the MCP tries to connect to Home Assistant at `http://homeassistant.local:8123`. If your Home Assistant is at a different URL, you can modify the `HA_URL` variable in `app/config.py`. ## Usage Once configured, you can use Cursor AI to control your Home Assistant devices: - Search for devices: "Find my living room lights" - Control devices: "Turn on the kitchen light" - Control light colors: "Set my living room lights to red" - Adjust brightness: "Set my dining room lights to blue at 50% brightness" ### Light Control Features The MCP now supports advanced light control capabilities: 1. **Color Control**: Set any RGB color for compatible lights - Specify colors using RGB values (0-255 for each component) - Example: `set_device_color("light.living_room", 255, 0, 0)` for red 2. **Brightness Control**: Adjust light brightness - Optional brightness parameter (0-255) - Can be combined with color changes - Example: `set_device_color("light.dining_room", 0, 0, 255, brightness=128)` for medium-bright blue ## Troubleshooting - If you get authentication errors, verify your token is correct and has not expired - Check that your Home Assistant instance is reachable at the configured URL - For color control issues: - Verify that your light entity supports RGB color control - Check that the light is turned on before attempting to change colors ## Future Capabilities ### Dynamic Entity Exposure The current implementation requires a two-step process to control devices: 1. Search for entities using natural language 2. Control the entity using its specific entity_id A planned enhancement is to create a more dynamic way to expose entities to the control devices tool, allowing the AI to: - Directly control devices through more natural commands (e.g., "turn off the kitchen lights") - Cache frequently used entities for faster access - Support more complex operations like adjusting brightness, temperature, or other attributes - Handle entity groups and scenes more intuitively 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 from .home_assistant import make_ha_request, get_all_entities __all__ = ['make_ha_request', 'get_all_entities'] ``` -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- ```python from .search import search_entities_by_keywords, format_entity_results __all__ = ['search_entities_by_keywords', 'format_entity_results'] ``` -------------------------------------------------------------------------------- /app/tools/__init__.py: -------------------------------------------------------------------------------- ```python from .device_controls import init_tools, control_device, search_entities, set_device_color __all__ = ['init_tools', 'control_device', 'search_entities', 'set_device_color'] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [project] name = "home-assistant" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.11" dependencies = [ "httpx>=0.28.1", "mcp[cli]>=1.4.1", ] ``` -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- ```python import os # Home Assistant API configuration HA_URL = "http://homeassistant.local:8123" HOME_ASSISTANT_TOKEN = os.environ.get("HOME_ASSISTANT_TOKEN") USER_AGENT = "home-assistant-mcp/1.0" def validate_config(): """Validate that the required configuration is present.""" if not HOME_ASSISTANT_TOKEN: raise ValueError("HOME_ASSISTANT_TOKEN environment variable is not set") return True ``` -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- ```python from .config import validate_config from .api import make_ha_request, get_all_entities from .tools import init_tools, control_device, search_entities, set_device_color from .utils import search_entities_by_keywords, format_entity_results __all__ = [ 'validate_config', 'make_ha_request', 'get_all_entities', 'init_tools', 'control_device', 'search_entities', 'set_device_color', 'search_entities_by_keywords', 'format_entity_results' ] ``` -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- ```python from mcp.server.fastmcp import FastMCP from app.config import validate_config from app.tools import init_tools # Initialize FastMCP server mcp = FastMCP("home-assistant") # Initialize the tools with the FastMCP instance init_tools(mcp) if __name__ == "__main__": # Validate configuration try: validate_config() except ValueError as e: print(f"Configuration error: {str(e)}") exit(1) # Initialize and run the server using stdio transport mcp.run(transport='stdio') ``` -------------------------------------------------------------------------------- /app/utils/search.py: -------------------------------------------------------------------------------- ```python import re from typing import List, Dict, Any def search_entities_by_keywords(entities: List[Dict[str, Any]], description: str) -> List[Dict[str, Any]]: """Search for entities matching a natural language description. Args: entities: List of entity dictionaries from Home Assistant description: Natural language description of the entity Returns: A list of matching entities sorted by relevance score """ # Break description into keywords keywords = re.findall(r'\w+', description.lower()) # Search for matching entities matches = [] for entity in entities: entity_id = entity.get("entity_id", "").lower() friendly_name = entity.get("attributes", {}).get("friendly_name", "").lower() # Check if any keyword matches the entity_id or friendly_name score = 0 for keyword in keywords: if keyword in entity_id or keyword in friendly_name: score += 1 if score > 0: matches.append({ "entity_id": entity.get("entity_id"), "friendly_name": entity.get("attributes", {}).get("friendly_name", ""), "score": score }) # Sort matches by score (descending) return sorted(matches, key=lambda x: x["score"], reverse=True) def format_entity_results(matches: List[Dict[str, Any]], limit: int = 5) -> str: """Format entity search results into a readable string. Args: matches: List of matching entities with scores limit: Maximum number of results to include Returns: Formatted string with entity results """ if matches: result = "Found matching entities:\n" for match in matches[:limit]: result += f"- {match['entity_id']} ({match['friendly_name']})\n" return result else: return "No matching entities found." ``` -------------------------------------------------------------------------------- /app/api/home_assistant.py: -------------------------------------------------------------------------------- ```python from typing import Dict, Any, List, Optional import httpx from ..config import HA_URL, HOME_ASSISTANT_TOKEN, USER_AGENT async def make_ha_request(domain: str, service: str, entity_id: str, additional_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Make a request to the Home Assistant API with proper error handling.""" url = f"{HA_URL}/api/services/{domain}/{service}" headers = { "Authorization": f"Bearer {HOME_ASSISTANT_TOKEN}", "Content-Type": "application/json", "User-Agent": USER_AGENT } payload = {"entity_id": entity_id} if additional_data: payload.update(additional_data) async with httpx.AsyncClient() as client: try: response = await client.post(url, headers=headers, json=payload, timeout=10.0) response.raise_for_status() return {"success": True, "status_code": response.status_code} except Exception as e: return {"success": False, "error": str(e)} async def set_light_color(entity_id: str, rgb_color: List[int], brightness: Optional[int] = None) -> Dict[str, Any]: """Set the color and optionally brightness of a light entity. Args: entity_id: The entity ID of the light rgb_color: List of 3 integers [r, g, b] each between 0 and 255 brightness: Optional brightness level between 0 and 255 """ additional_data = { "rgb_color": rgb_color } if brightness is not None: additional_data["brightness"] = brightness return await make_ha_request("light", "turn_on", entity_id, additional_data) async def get_all_entities() -> List[Dict[str, Any]]: """Fetch all entities from Home Assistant.""" url = f"{HA_URL}/api/states" headers = { "Authorization": f"Bearer {HOME_ASSISTANT_TOKEN}", "Content-Type": "application/json", "User-Agent": USER_AGENT } async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers, timeout=10.0) response.raise_for_status() return response.json() except Exception as e: return [] ``` -------------------------------------------------------------------------------- /app/tools/device_controls.py: -------------------------------------------------------------------------------- ```python from typing import Dict, Any, List from mcp.server.fastmcp import FastMCP from ..api.home_assistant import make_ha_request, get_all_entities, set_light_color from ..config import HOME_ASSISTANT_TOKEN from ..utils.search import search_entities_by_keywords, format_entity_results # Tools need to be registered with a FastMCP instance # This will be initialized in the main.py file and passed here mcp = None def init_tools(fastmcp_instance: FastMCP): """Initialize the tools with a FastMCP instance.""" global mcp mcp = fastmcp_instance # Register tools with the FastMCP instance fastmcp_instance.tool()(control_device) fastmcp_instance.tool()(search_entities) fastmcp_instance.tool()(set_device_color) # Register set_device_color like other tools async def control_device(entity_id: str, state: str) -> str: """Control a Home Assistant entity by turning it on or off. Args: entity_id: The Home Assistant entity ID to control (format: domain.entity) state: The desired state ('on' or 'off') """ # Basic validation if not entity_id or "." not in entity_id: return f"Invalid entity ID format: {entity_id}. Must be in format: domain.entity" state = state.lower() if state not in ["on", "off"]: return f"Invalid state: {state}. Must be 'on' or 'off'" # Check token if not HOME_ASSISTANT_TOKEN: return "Home Assistant token not configured. Set HOME_ASSISTANT_TOKEN environment variable." # Get domain from entity_id domain = entity_id.split(".")[0] service = "turn_on" if state == "on" else "turn_off" # Call the HA API result = await make_ha_request(domain, service, entity_id) if result["success"]: return f"Successfully turned {state} {entity_id}" else: return f"Failed to control {entity_id}: {result.get('error', 'Unknown error')}" async def set_device_color(entity_id: str, red: int, green: int, blue: int, brightness: int = None) -> str: """Set the color and optionally brightness of a light entity. Args: entity_id: The Home Assistant entity ID to control (format: light.entity) red: Red component (0-255) green: Green component (0-255) blue: Blue component (0-255) brightness: Optional brightness level (0-255) """ # Basic validation if not entity_id or not entity_id.startswith("light."): return f"Invalid entity ID format: {entity_id}. Must be a light entity (format: light.entity)" # Validate RGB values for color, name in [(red, "Red"), (green, "Green"), (blue, "Blue")]: if not 0 <= color <= 255: return f"Invalid {name} value: {color}. Must be between 0 and 255" # Validate brightness if provided if brightness is not None and not 0 <= brightness <= 255: return f"Invalid brightness value: {brightness}. Must be between 0 and 255" # Check token if not HOME_ASSISTANT_TOKEN: return "Home Assistant token not configured. Set HOME_ASSISTANT_TOKEN environment variable." # Call the HA API result = await set_light_color(entity_id, [red, green, blue], brightness) if result["success"]: color_msg = f"color to RGB({red},{green},{blue})" brightness_msg = f" and brightness to {brightness}" if brightness is not None else "" return f"Successfully set {entity_id} {color_msg}{brightness_msg}" else: return f"Failed to set color for {entity_id}: {result.get('error', 'Unknown error')}" async def search_entities(description: str) -> str: """Search for Home Assistant entities matching a natural language description. Args: description: Natural language description of the entity (e.g., "office light", "kitchen fan") Returns: A list of matching entity IDs with their friendly names, or an error message """ # Check token if not HOME_ASSISTANT_TOKEN: return "Home Assistant token not configured. Set HOME_ASSISTANT_TOKEN environment variable." # Get all entities entities = await get_all_entities() if not entities: return "Failed to retrieve entities from Home Assistant." # Search and format results matches = search_entities_by_keywords(entities, description) return format_entity_results(matches) ```