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