#
tokens: 4910/50000 13/13 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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)
```