Skip to content

Commit 58893b0

Browse files
committed
add HA
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 9b63e49 commit 58893b0

5 files changed

Lines changed: 325 additions & 1 deletion

File tree

.github/workflows/image.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
runs-on: ubuntu-latest
1818
strategy:
1919
matrix:
20-
mcp: [duckduckgo, weather, memory]
20+
mcp: [duckduckgo, weather, memory, homeassistant]
2121
permissions:
2222
packages: write
2323
contents: read

README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,78 @@ mcp:
171171
}
172172
```
173173

174+
### 🏠 Home Assistant Server
175+
176+
A Home Assistant integration server that allows AI models to interact with and control Home Assistant entities and services.
177+
178+
**Features:**
179+
- List all entities and their current states
180+
- Get all available services with detailed information
181+
- Call services to control devices (turn_on, turn_off, toggle, etc.)
182+
183+
**Tools:**
184+
- `list_entities` - List all entities in Home Assistant
185+
- `get_services` - Get all available services in Home Assistant
186+
- `call_service` - Call a service in Home Assistant (e.g., turn_on, turn_off, toggle)
187+
188+
**Configuration:**
189+
- `HA_TOKEN` - Home Assistant API token (required)
190+
- `HA_HOST` - Home Assistant host URL (default: `http://localhost:8123`)
191+
192+
**Entity Response Format:**
193+
```json
194+
{
195+
"entities": [
196+
{
197+
"entity_id": "light.living_room",
198+
"state": "on",
199+
"friendly_name": "Living Room Light",
200+
"attributes": {
201+
"friendly_name": "Living Room Light",
202+
"brightness": 255
203+
},
204+
"domain": "light"
205+
}
206+
],
207+
"count": 1
208+
}
209+
```
210+
211+
**Service Call Example:**
212+
```json
213+
{
214+
"domain": "light",
215+
"service": "turn_on",
216+
"entity_id": "light.living_room"
217+
}
218+
```
219+
220+
**Docker Image:**
221+
```bash
222+
docker run -e HA_TOKEN="your-token-here" -e HA_HOST="http://IP:PORT" ghcr.io/mudler/mcps/homeassistant:latest
223+
```
224+
225+
**LocalAI configuration ( to add to the model config):**
226+
```yaml
227+
mcp:
228+
stdio: |
229+
{
230+
"mcpServers": {
231+
"homeassistant": {
232+
"command": "docker",
233+
"env": {
234+
"HA_TOKEN": "your-home-assistant-token",
235+
"HA_HOST": "http://"
236+
},
237+
"args": [
238+
"run", "-i", "--rm",
239+
"ghcr.io/mudler/mcps/homeassistant:master"
240+
]
241+
}
242+
}
243+
}
244+
```
245+
174246
## Development
175247

176248
### Prerequisites
@@ -252,6 +324,9 @@ Docker images are automatically built and pushed to GitHub Container Registry:
252324
- `ghcr.io/mudler/mcps/memory:latest` - Latest Memory server
253325
- `ghcr.io/mudler/mcps/memory:v1.0.0` - Tagged versions
254326
- `ghcr.io/mudler/mcps/memory:master` - Development versions
327+
- `ghcr.io/mudler/mcps/homeassistant:latest` - Latest Home Assistant server
328+
- `ghcr.io/mudler/mcps/homeassistant:v1.0.0` - Tagged versions
329+
- `ghcr.io/mudler/mcps/homeassistant:master` - Development versions
255330

256331
## Contributing
257332

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/mudler/mcps
33
go 1.24.7
44

55
require (
6+
github.com/mkelcik/go-ha-client v1.0.0
67
github.com/modelcontextprotocol/go-sdk v1.0.0
78
github.com/tmc/langchaingo v0.1.13
89
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
3838
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
3939
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4040
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
41+
github.com/mkelcik/go-ha-client v1.0.0 h1:L49uN3ucuG0jTW27MoAc5kY8JblznGXVs83/ZHxUEB8=
42+
github.com/mkelcik/go-ha-client v1.0.0/go.mod h1:X8bXN+jvTbDsyw6Km6f6Hq6Fof+cgW9Bd5DOTiNcwHA=
4143
github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
4244
github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
4345
github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=

homeassistant/main.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
ha "github.com/mkelcik/go-ha-client"
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
)
15+
16+
// Global Home Assistant client
17+
var client *ha.Client
18+
19+
// Input types
20+
type ListEntitiesInput struct {
21+
}
22+
23+
type GetServicesInput struct {
24+
}
25+
26+
type CallServiceInput struct {
27+
Domain string `json:"domain" jsonschema:"the domain of the service (e.g., 'switch', 'light')"`
28+
Service string `json:"service" jsonschema:"the service name (e.g., 'turn_on', 'turn_off')"`
29+
EntityID string `json:"entity_id" jsonschema:"the entity ID (e.g., 'switch.switch_1')"`
30+
}
31+
32+
// Output types
33+
type Entity struct {
34+
EntityID string `json:"entity_id" jsonschema:"the entity ID"`
35+
State string `json:"state" jsonschema:"current state"`
36+
FriendlyName interface{} `json:"friendly_name" jsonschema:"friendly name if available"`
37+
Domain string `json:"domain" jsonschema:"domain of the entity"`
38+
}
39+
40+
type ListEntitiesOutput struct {
41+
Entities []Entity `json:"entities" jsonschema:"list of all entities"`
42+
Count int `json:"count" jsonschema:"number of entities"`
43+
}
44+
45+
type ServiceField struct {
46+
Description string `json:"description" jsonschema:"field description"`
47+
Example string `json:"example,omitempty" jsonschema:"example value"`
48+
Required bool `json:"required,omitempty" jsonschema:"whether the field is required"`
49+
}
50+
51+
type Service struct {
52+
Domain string `json:"domain" jsonschema:"service domain"`
53+
Name string `json:"name" jsonschema:"service name"`
54+
Fields map[string]ServiceField `json:"fields" jsonschema:"service fields"`
55+
}
56+
57+
type GetServicesOutput struct {
58+
Services []Service `json:"services" jsonschema:"list of all available services"`
59+
Count int `json:"count" jsonschema:"number of services"`
60+
}
61+
62+
type CallServiceOutput struct {
63+
Success bool `json:"success" jsonschema:"whether the call was successful"`
64+
Message string `json:"message" jsonschema:"status message"`
65+
}
66+
67+
// ListEntities returns all entities in Home Assistant
68+
func ListEntities(ctx context.Context, req *mcp.CallToolRequest, input ListEntitiesInput) (
69+
*mcp.CallToolResult,
70+
ListEntitiesOutput,
71+
error,
72+
) {
73+
states, err := client.GetStates(ctx)
74+
if err != nil {
75+
return nil, ListEntitiesOutput{}, fmt.Errorf("failed to get states: %w", err)
76+
}
77+
78+
var entities []Entity
79+
for _, state := range states {
80+
// Extract domain from entity ID
81+
data := strings.Split(state.EntityId, ".")
82+
domain := ""
83+
if len(data) > 0 {
84+
domain = data[0]
85+
}
86+
87+
// Extract friendly name if available
88+
friendlyName := state.Attributes["friendly_name"]
89+
90+
entity := Entity{
91+
EntityID: state.EntityId,
92+
State: state.State,
93+
FriendlyName: friendlyName,
94+
Domain: domain,
95+
}
96+
entities = append(entities, entity)
97+
}
98+
99+
output := ListEntitiesOutput{
100+
Entities: entities,
101+
Count: len(entities),
102+
}
103+
104+
return nil, output, nil
105+
}
106+
107+
// GetServices returns all available services in Home Assistant
108+
func GetServices(ctx context.Context, req *mcp.CallToolRequest, input GetServicesInput) (
109+
*mcp.CallToolResult,
110+
GetServicesOutput,
111+
error,
112+
) {
113+
services, err := client.GetServices(ctx)
114+
if err != nil {
115+
return nil, GetServicesOutput{}, fmt.Errorf("failed to get services: %w", err)
116+
}
117+
118+
var result []Service
119+
for _, s := range services {
120+
for serviceName, serviceInfo := range s.Services {
121+
// Convert service fields to our format
122+
fields := make(map[string]ServiceField)
123+
for fieldName, fieldInfo := range serviceInfo.Fields {
124+
field := ServiceField{
125+
Description: fieldInfo.Description,
126+
}
127+
128+
// Set example if available (convert to string if needed)
129+
if fieldInfo.Example != nil {
130+
switch v := fieldInfo.Example.(type) {
131+
case string:
132+
field.Example = v
133+
default:
134+
field.Example = fmt.Sprintf("%v", v)
135+
}
136+
}
137+
138+
// Set required flag if available
139+
if fieldInfo.Selector != nil {
140+
field.Required = true // Set based on selector if needed
141+
}
142+
143+
fields[fieldName] = field
144+
}
145+
146+
service := Service{
147+
Domain: s.Domain,
148+
Name: serviceName,
149+
Fields: fields,
150+
}
151+
result = append(result, service)
152+
}
153+
}
154+
155+
output := GetServicesOutput{
156+
Services: result,
157+
Count: len(result),
158+
}
159+
160+
return nil, output, nil
161+
}
162+
163+
// CallService calls a service in Home Assistant
164+
func CallService(ctx context.Context, req *mcp.CallToolRequest, input CallServiceInput) (
165+
*mcp.CallToolResult,
166+
CallServiceOutput,
167+
error,
168+
) {
169+
// Prepare the service command
170+
cmd := ha.DefaultServiceCmd{
171+
Domain: input.Domain,
172+
Service: input.Service,
173+
EntityId: input.EntityID,
174+
}
175+
176+
// Call the service
177+
_, err := client.CallService(ctx, cmd)
178+
if err != nil {
179+
return nil, CallServiceOutput{
180+
Success: false,
181+
Message: fmt.Sprintf("Failed to call service: %v", err),
182+
}, nil
183+
}
184+
185+
output := CallServiceOutput{
186+
Success: true,
187+
Message: fmt.Sprintf("Successfully called %s.%s on entity %s", input.Domain, input.Service, input.EntityID),
188+
}
189+
190+
return nil, output, nil
191+
}
192+
193+
func main() {
194+
// Get configuration from environment variables
195+
token := os.Getenv("HA_TOKEN")
196+
if token == "" {
197+
log.Fatal("HA_TOKEN environment variable is required")
198+
}
199+
200+
host := os.Getenv("HA_HOST")
201+
if host == "" {
202+
host = "http://localhost:8123"
203+
}
204+
205+
// Create Home Assistant client
206+
client = ha.NewClient(
207+
ha.ClientConfig{
208+
Token: token,
209+
Host: host,
210+
},
211+
&http.Client{
212+
Timeout: 30 * time.Second,
213+
},
214+
)
215+
216+
// Test connection
217+
if err := client.Ping(context.Background()); err != nil {
218+
log.Printf("Warning: Could not ping Home Assistant instance: %v", err)
219+
} else {
220+
log.Println("Connected to Home Assistant instance")
221+
}
222+
223+
// Create MCP server with Home Assistant tools
224+
server := mcp.NewServer(&mcp.Implementation{Name: "homeassistant", Version: "v1.0.0"}, nil)
225+
226+
// Register tools
227+
mcp.AddTool(server, &mcp.Tool{
228+
Name: "list_entities",
229+
Description: "List all entities in Home Assistant and their current states",
230+
}, ListEntities)
231+
232+
mcp.AddTool(server, &mcp.Tool{
233+
Name: "get_services",
234+
Description: "Get all available services in Home Assistant",
235+
}, GetServices)
236+
237+
mcp.AddTool(server, &mcp.Tool{
238+
Name: "call_service",
239+
Description: "Call a service in Home Assistant (e.g., turn_on, turn_off, toggle)",
240+
}, CallService)
241+
242+
// Run the server
243+
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
244+
log.Fatal(err)
245+
}
246+
}

0 commit comments

Comments
 (0)