@orellbuehler/homeassistant-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/config.js +26 -0
- package/dist/hass/format.js +38 -0
- package/dist/hass/rest.js +42 -0
- package/dist/hass/ws.js +75 -0
- package/dist/index.js +7 -0
- package/dist/server.js +19 -0
- package/dist/tools/entities.js +57 -0
- package/dist/tools/history.js +57 -0
- package/dist/tools/registry.js +66 -0
- package/dist/tools/reload.js +40 -0
- package/dist/tools/services.js +33 -0
- package/dist/tools/system.js +39 -0
- package/dist/tools/templates.js +21 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Orell Bühler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# homeassistant-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Home Assistant](https://www.home-assistant.io/) that exposes the
|
|
4
|
+
[REST API](https://www.home-assistant.io/integrations/api/) and WebSocket registries as tools for
|
|
5
|
+
AI agents.
|
|
6
|
+
|
|
7
|
+
It is built to **help an agent author and validate Home Assistant configuration and automations** —
|
|
8
|
+
not to control your home. It tells the agent what exists live (entities, services, events, areas,
|
|
9
|
+
devices), validates the agent's work (render Jinja2 templates, check config, read the error log), and
|
|
10
|
+
can reload reloadable domains after YAML edits. It deliberately has **no tools to turn devices on/off,
|
|
11
|
+
set states, or fire events**.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
The package is published as
|
|
16
|
+
[`@orellbuehler/homeassistant-mcp`](https://www.npmjs.com/package/@orellbuehler/homeassistant-mcp)
|
|
17
|
+
and runs directly with `npx` — no clone or build needed:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add homeassistant \
|
|
21
|
+
--env HASS_URL=http://homeassistant.local:8123 \
|
|
22
|
+
--env HASS_TOKEN=your-long-lived-access-token \
|
|
23
|
+
-- npx -y @orellbuehler/homeassistant-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
See [Usage with Claude Code](#usage-with-claude-code) for the equivalent JSON config.
|
|
27
|
+
|
|
28
|
+
## Getting a token
|
|
29
|
+
|
|
30
|
+
`HASS_TOKEN` must be a **Long-Lived Access Token**:
|
|
31
|
+
|
|
32
|
+
1. In Home Assistant, open your profile (click your username, bottom-left).
|
|
33
|
+
2. Go to the **Security** tab → **Long-lived access tokens** → **Create token**.
|
|
34
|
+
3. Copy the token (it is shown only once) and treat it like a password.
|
|
35
|
+
|
|
36
|
+
The token inherits your user's permissions. To limit what the agent can see, create a dedicated HA
|
|
37
|
+
user with the access you want and generate the token as that user.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
| Variable | Required | Description |
|
|
42
|
+
| ------------- | -------- | --------------------------------------------------------------------------------- |
|
|
43
|
+
| `HASS_URL` | yes\* | Base URL of your instance, e.g. `http://homeassistant.local:8123`. `https` works. |
|
|
44
|
+
| `HASS_SERVER` | no | Alias for `HASS_URL` (compatible with `hass-cli`). Used if `HASS_URL` is unset. |
|
|
45
|
+
| `HASS_TOKEN` | yes | Long-lived access token. |
|
|
46
|
+
|
|
47
|
+
\* Either `HASS_URL` or `HASS_SERVER` must be set. The WebSocket URL is derived automatically
|
|
48
|
+
(`http`→`ws`, `https`→`wss`, plus `/api/websocket`).
|
|
49
|
+
|
|
50
|
+
## Usage with Claude Code
|
|
51
|
+
|
|
52
|
+
Add the server to `~/.claude/settings.json` (or a project `.mcp.json`):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"homeassistant": {
|
|
58
|
+
"command": "npx",
|
|
59
|
+
"args": ["-y", "@orellbuehler/homeassistant-mcp"],
|
|
60
|
+
"env": {
|
|
61
|
+
"HASS_URL": "http://homeassistant.local:8123",
|
|
62
|
+
"HASS_TOKEN": "your-long-lived-access-token"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If you built from source instead, use `"command": "node"` with
|
|
70
|
+
`"args": ["/path/to/homeassistant-mcp/dist/index.js"]`. Restart Claude Code and the tools are
|
|
71
|
+
available immediately. Verify with `claude mcp list` (should show `homeassistant ✓ connected`) or
|
|
72
|
+
`/mcp` inside a session.
|
|
73
|
+
|
|
74
|
+
## Tools
|
|
75
|
+
|
|
76
|
+
**Entities & state** (REST)
|
|
77
|
+
|
|
78
|
+
| Tool | Description |
|
|
79
|
+
| --------------- | ---------------------------------------------------------------------- |
|
|
80
|
+
| `list_entities` | Compact summary of live entities (filter by `domain` and/or `search`). |
|
|
81
|
+
| `get_entity` | Full state object for one entity, including all attributes. |
|
|
82
|
+
| `list_domains` | Distinct entity domains present, with counts. |
|
|
83
|
+
|
|
84
|
+
**Services & events** (REST)
|
|
85
|
+
|
|
86
|
+
| Tool | Description |
|
|
87
|
+
| --------------- | ----------------------------------------------------------------------------------- |
|
|
88
|
+
| `list_services` | Callable services (actions for automations/scripts). Pass `domain` for full fields. |
|
|
89
|
+
| `list_events` | Event types being listened for, with listener counts (for `event` triggers). |
|
|
90
|
+
|
|
91
|
+
**System & validation** (REST)
|
|
92
|
+
|
|
93
|
+
| Tool | Description |
|
|
94
|
+
| ----------------- | ------------------------------------------------------------------------------ |
|
|
95
|
+
| `get_config` | Running config: version, components, unit system, time zone, etc. |
|
|
96
|
+
| `check_config` | Validate the YAML config on the server (`/api/config/core/check_config`). |
|
|
97
|
+
| `get_error_log` | Error log as text (tail to `lines`, default 100). |
|
|
98
|
+
| `render_template` | Render a Jinja2 template against live state — for template sensors/conditions. |
|
|
99
|
+
|
|
100
|
+
**History** (REST)
|
|
101
|
+
|
|
102
|
+
| Tool | Description |
|
|
103
|
+
| ------------- | ---------------------------------------------------------- |
|
|
104
|
+
| `get_history` | State history for one or more entities over a time window. |
|
|
105
|
+
| `get_logbook` | Human-readable logbook entries. |
|
|
106
|
+
|
|
107
|
+
**Reload** (REST, curated allowlist)
|
|
108
|
+
|
|
109
|
+
| Tool | Description |
|
|
110
|
+
| -------- | ------------------------------------------------------------------------------------------ |
|
|
111
|
+
| `reload` | Reload a reloadable domain so YAML edits apply without a restart (`all`, `automation`, …). |
|
|
112
|
+
|
|
113
|
+
**Registries** (WebSocket)
|
|
114
|
+
|
|
115
|
+
| Tool | Description |
|
|
116
|
+
| ------------------------ | --------------------------------------------------------------------------- |
|
|
117
|
+
| `list_registry_entities` | ALL registered entities, incl. disabled/unavailable (area, device, status). |
|
|
118
|
+
| `list_devices` | Devices (id, name, manufacturer, model, area). |
|
|
119
|
+
| `list_areas` | Areas (area_id, name, floor). |
|
|
120
|
+
| `list_labels` | Labels (label_id, name, color, icon). |
|
|
121
|
+
|
|
122
|
+
`list_entities` (REST) shows entities that currently have state; `list_registry_entities`
|
|
123
|
+
(WebSocket) shows everything registered, including disabled entities, with area/device grouping.
|
|
124
|
+
|
|
125
|
+
## Safety boundary
|
|
126
|
+
|
|
127
|
+
- **No device control.** There is no generic `call_service`, no `set_state`, and no `fire_event`.
|
|
128
|
+
The server cannot turn things on/off.
|
|
129
|
+
- **`reload` is restricted** to a fixed allowlist of `*.reload` / `homeassistant.reload_*` services.
|
|
130
|
+
It restarts reloadable domains (e.g. re-reads `automations.yaml`) but cannot control devices.
|
|
131
|
+
- **Read tools expose your home's data** (entity names, states, areas) to the agent/LLM. Use a
|
|
132
|
+
scoped HA user if that matters to you.
|
|
133
|
+
- Prefer `https` and a trusted network path to your instance.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install
|
|
139
|
+
npm run build # tsc -> dist/
|
|
140
|
+
npm test # vitest run
|
|
141
|
+
npm run typecheck # tsc --noEmit
|
|
142
|
+
npm run lint # eslint src
|
|
143
|
+
npm run format:check # prettier --check .
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run a single test file:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx vitest run src/__tests__/entities.test.ts
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## CI / Releasing
|
|
153
|
+
|
|
154
|
+
- **CI** (`.github/workflows/ci.yml`) runs on every push to `main` and on pull requests:
|
|
155
|
+
`format:check`, `lint`, `typecheck` (once) and `test` + `build` on Node 20 and 22.
|
|
156
|
+
- **Publish** (`.github/workflows/publish.yml`) runs when a GitHub Release is published. It builds,
|
|
157
|
+
tests, and publishes to npm using [trusted publishing](https://docs.npmjs.com/trusted-publishers)
|
|
158
|
+
(OIDC) — **no `NPM_TOKEN` secret required**, with provenance generated automatically. It skips
|
|
159
|
+
publishing if that version is already on npm.
|
|
160
|
+
|
|
161
|
+
One-time setup on npmjs.com: open the package's **Settings → Trusted Publisher** and add a GitHub
|
|
162
|
+
Actions publisher for repository `OrellBuehler/homeassistant-mcp` with workflow `publish.yml`. If the
|
|
163
|
+
package doesn't exist on npm yet, do one manual `npm publish --access public` (after `npm login`) to
|
|
164
|
+
create it, then add the trusted publisher for all future releases.
|
|
165
|
+
|
|
166
|
+
Cut a release:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npm version patch # bumps package.json + creates a vX.Y.Z tag (use minor/major as needed)
|
|
170
|
+
git push --follow-tags
|
|
171
|
+
gh release create "v$(node -p "require('./package.json').version")" --generate-notes
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](./LICENSE) © Orell Bühler
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { HassClient } from "./hass/rest.js";
|
|
2
|
+
import { HassWsClient } from "./hass/ws.js";
|
|
3
|
+
const rawUrl = (process.env.HASS_URL || process.env.HASS_SERVER || "").trim();
|
|
4
|
+
const token = (process.env.HASS_TOKEN || "").trim();
|
|
5
|
+
if (!rawUrl || !token) {
|
|
6
|
+
console.error("HASS_URL (or HASS_SERVER) and HASS_TOKEN environment variables are required. " +
|
|
7
|
+
"HASS_TOKEN must be a Home Assistant long-lived access token.");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
function normalizeBaseUrl(url) {
|
|
11
|
+
const withScheme = /^https?:\/\//i.test(url) ? url : `http://${url}`;
|
|
12
|
+
return withScheme.replace(/\/+$/, "");
|
|
13
|
+
}
|
|
14
|
+
export function deriveWsUrl(baseUrl) {
|
|
15
|
+
const ws = baseUrl.replace(/^https:/i, "wss:").replace(/^http:/i, "ws:");
|
|
16
|
+
return `${ws}/api/websocket`;
|
|
17
|
+
}
|
|
18
|
+
const baseUrl = normalizeBaseUrl(rawUrl);
|
|
19
|
+
export const config = {
|
|
20
|
+
baseUrl,
|
|
21
|
+
wsUrl: deriveWsUrl(baseUrl),
|
|
22
|
+
token,
|
|
23
|
+
transport: "stdio",
|
|
24
|
+
};
|
|
25
|
+
export const client = new HassClient(baseUrl, token);
|
|
26
|
+
export const wsClient = new HassWsClient(config.wsUrl, token);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function buildQS(params) {
|
|
2
|
+
const sp = new URLSearchParams();
|
|
3
|
+
for (const [k, v] of Object.entries(params)) {
|
|
4
|
+
if (v === undefined || v === null)
|
|
5
|
+
continue;
|
|
6
|
+
if (Array.isArray(v)) {
|
|
7
|
+
if (v.length === 0)
|
|
8
|
+
continue;
|
|
9
|
+
sp.set(k, v.join(","));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
sp.set(k, String(v));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const s = sp.toString();
|
|
16
|
+
return s ? `?${s}` : "";
|
|
17
|
+
}
|
|
18
|
+
export function ok(data) {
|
|
19
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
20
|
+
return { content: [{ type: "text", text }] };
|
|
21
|
+
}
|
|
22
|
+
export function err(e) {
|
|
23
|
+
return { content: [{ type: "text", text: String(e) }], isError: true };
|
|
24
|
+
}
|
|
25
|
+
export function domainOf(entityId) {
|
|
26
|
+
return entityId.split(".")[0];
|
|
27
|
+
}
|
|
28
|
+
export function summarizeState(s) {
|
|
29
|
+
const a = s.attributes ?? {};
|
|
30
|
+
const out = { entity_id: s.entity_id, state: s.state };
|
|
31
|
+
if (a.friendly_name !== undefined)
|
|
32
|
+
out.friendly_name = a.friendly_name;
|
|
33
|
+
if (a.device_class !== undefined)
|
|
34
|
+
out.device_class = a.device_class;
|
|
35
|
+
if (a.unit_of_measurement !== undefined)
|
|
36
|
+
out.unit = a.unit_of_measurement;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class HassClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
token;
|
|
4
|
+
constructor(baseUrl, token) {
|
|
5
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
6
|
+
this.token = token;
|
|
7
|
+
}
|
|
8
|
+
headers(options) {
|
|
9
|
+
return {
|
|
10
|
+
Authorization: `Bearer ${this.token}`,
|
|
11
|
+
...(typeof options.body === "string" ? { "Content-Type": "application/json" } : {}),
|
|
12
|
+
...(options.headers ?? {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async fetch(path, options = {}) {
|
|
16
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
17
|
+
...options,
|
|
18
|
+
headers: this.headers(options),
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const body = await res.text();
|
|
22
|
+
throw new Error(`${res.status} ${res.statusText}: ${body}`);
|
|
23
|
+
}
|
|
24
|
+
if (res.status === 204)
|
|
25
|
+
return { success: true };
|
|
26
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
27
|
+
if (ct.includes("application/json"))
|
|
28
|
+
return res.json();
|
|
29
|
+
return res.text();
|
|
30
|
+
}
|
|
31
|
+
async fetchText(path, options = {}) {
|
|
32
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
33
|
+
...options,
|
|
34
|
+
headers: this.headers(options),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = await res.text();
|
|
38
|
+
throw new Error(`${res.status} ${res.statusText}: ${body}`);
|
|
39
|
+
}
|
|
40
|
+
return res.text();
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/hass/ws.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
export class HassWsClient {
|
|
3
|
+
wsUrl;
|
|
4
|
+
token;
|
|
5
|
+
timeoutMs;
|
|
6
|
+
constructor(wsUrl, token, timeoutMs = 15000) {
|
|
7
|
+
this.wsUrl = wsUrl;
|
|
8
|
+
this.token = token;
|
|
9
|
+
this.timeoutMs = timeoutMs;
|
|
10
|
+
}
|
|
11
|
+
command(type, payload = {}) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const ws = new WebSocket(this.wsUrl);
|
|
14
|
+
const id = 1;
|
|
15
|
+
let settled = false;
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
ws.terminate();
|
|
21
|
+
reject(new Error(`Home Assistant WebSocket timed out after ${this.timeoutMs}ms`));
|
|
22
|
+
}, this.timeoutMs);
|
|
23
|
+
const finish = (run) => {
|
|
24
|
+
if (settled)
|
|
25
|
+
return;
|
|
26
|
+
settled = true;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
try {
|
|
29
|
+
ws.close();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore close errors */
|
|
33
|
+
}
|
|
34
|
+
run();
|
|
35
|
+
};
|
|
36
|
+
ws.on("message", (raw) => {
|
|
37
|
+
let msg;
|
|
38
|
+
try {
|
|
39
|
+
msg = JSON.parse(raw.toString());
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
finish(() => reject(new Error(`Invalid Home Assistant WebSocket message: ${String(e)}`)));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (msg.type === "auth_required") {
|
|
46
|
+
ws.send(JSON.stringify({ type: "auth", access_token: this.token }));
|
|
47
|
+
}
|
|
48
|
+
else if (msg.type === "auth_invalid") {
|
|
49
|
+
finish(() => reject(new Error(`Home Assistant authentication failed: ${msg.message ?? "invalid token"}`)));
|
|
50
|
+
}
|
|
51
|
+
else if (msg.type === "auth_ok") {
|
|
52
|
+
ws.send(JSON.stringify({ id, type, ...payload }));
|
|
53
|
+
}
|
|
54
|
+
else if (msg.type === "result" && msg.id === id) {
|
|
55
|
+
if (msg.success) {
|
|
56
|
+
finish(() => resolve(msg.result));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
finish(() => reject(new Error(`Home Assistant WebSocket error: ${msg.error?.message ?? "unknown error"}`)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
ws.on("error", (e) => {
|
|
64
|
+
finish(() => reject(new Error(`Home Assistant WebSocket connection error: ${String(e)}`)));
|
|
65
|
+
});
|
|
66
|
+
ws.on("close", () => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
reject(new Error("Home Assistant WebSocket closed before a result was received"));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { client, wsClient } from "./config.js";
|
|
4
|
+
import { createServer } from "./server.js";
|
|
5
|
+
const server = createServer(client, wsClient);
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerEntityTools } from "./tools/entities.js";
|
|
3
|
+
import { registerServiceTools } from "./tools/services.js";
|
|
4
|
+
import { registerSystemTools } from "./tools/system.js";
|
|
5
|
+
import { registerTemplateTools } from "./tools/templates.js";
|
|
6
|
+
import { registerHistoryTools } from "./tools/history.js";
|
|
7
|
+
import { registerReloadTools } from "./tools/reload.js";
|
|
8
|
+
import { registerRegistryTools } from "./tools/registry.js";
|
|
9
|
+
export function createServer(client, wsClient) {
|
|
10
|
+
const server = new McpServer({ name: "homeassistant-mcp", version: "0.1.0" });
|
|
11
|
+
registerEntityTools(server, client);
|
|
12
|
+
registerServiceTools(server, client);
|
|
13
|
+
registerSystemTools(server, client);
|
|
14
|
+
registerTemplateTools(server, client);
|
|
15
|
+
registerHistoryTools(server, client);
|
|
16
|
+
registerReloadTools(server, client);
|
|
17
|
+
registerRegistryTools(server, wsClient);
|
|
18
|
+
return server;
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err, summarizeState, domainOf } from "../hass/format.js";
|
|
3
|
+
export function registerEntityTools(server, client) {
|
|
4
|
+
server.tool("list_entities", "List entities with their current state as a compact summary (entity_id, state, friendly_name, device_class, unit). Use get_entity for full attributes. Optionally filter by domain and/or a search substring matched against entity_id and friendly_name.", {
|
|
5
|
+
domain: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe("Only entities in this domain, e.g. 'light', 'sensor', 'binary_sensor'"),
|
|
9
|
+
search: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Case-insensitive substring matched against entity_id and friendly_name"),
|
|
13
|
+
}, async ({ domain, search }) => {
|
|
14
|
+
try {
|
|
15
|
+
const states = (await client.fetch("/api/states"));
|
|
16
|
+
let rows = states;
|
|
17
|
+
if (domain)
|
|
18
|
+
rows = rows.filter((s) => domainOf(s.entity_id) === domain);
|
|
19
|
+
if (search) {
|
|
20
|
+
const q = search.toLowerCase();
|
|
21
|
+
rows = rows.filter((s) => s.entity_id.toLowerCase().includes(q) ||
|
|
22
|
+
String(s.attributes?.friendly_name ?? "")
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.includes(q));
|
|
25
|
+
}
|
|
26
|
+
return ok({ count: rows.length, entities: rows.map(summarizeState) });
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return err(e);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
server.tool("get_entity", "Get the full state object for a single entity, including all attributes, last_changed and last_updated.", { entity_id: z.string().describe("Entity ID, e.g. 'light.kitchen'") }, async ({ entity_id }) => {
|
|
33
|
+
try {
|
|
34
|
+
return ok(await client.fetch(`/api/states/${encodeURIComponent(entity_id)}`));
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return err(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
server.tool("list_domains", "List the distinct entity domains present on this instance with a count of entities in each. Useful for discovering what kinds of entities exist before drilling in with list_entities.", {}, async () => {
|
|
41
|
+
try {
|
|
42
|
+
const states = (await client.fetch("/api/states"));
|
|
43
|
+
const counts = new Map();
|
|
44
|
+
for (const s of states) {
|
|
45
|
+
const d = domainOf(s.entity_id);
|
|
46
|
+
counts.set(d, (counts.get(d) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
const domains = [...counts.entries()]
|
|
49
|
+
.map(([domain, count]) => ({ domain, count }))
|
|
50
|
+
.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
51
|
+
return ok({ count: domains.length, domains });
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return err(e);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildQS, ok, err } from "../hass/format.js";
|
|
3
|
+
export function registerHistoryTools(server, client) {
|
|
4
|
+
server.tool("get_history", "Get state history for one or more entities (GET /api/history/period). Returns arrays of past state changes — useful for understanding how an entity behaves before writing triggers and conditions.", {
|
|
5
|
+
entity_id: z
|
|
6
|
+
.string()
|
|
7
|
+
.describe("Entity ID, or a comma-separated list of entity IDs, to fetch history for"),
|
|
8
|
+
start: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("ISO 8601 start timestamp, e.g. '2026-06-01T00:00:00'. Defaults to 1 day ago on the HA side."),
|
|
12
|
+
end: z.string().optional().describe("ISO 8601 end timestamp"),
|
|
13
|
+
minimal_response: z
|
|
14
|
+
.boolean()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Return only last_changed and state for most entries (smaller payload)"),
|
|
17
|
+
significant_changes_only: z
|
|
18
|
+
.boolean()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Only return significant state changes"),
|
|
21
|
+
no_attributes: z.boolean().optional().describe("Omit attributes from the response"),
|
|
22
|
+
}, async ({ entity_id, start, end, minimal_response, significant_changes_only, no_attributes, }) => {
|
|
23
|
+
try {
|
|
24
|
+
const base = start
|
|
25
|
+
? `/api/history/period/${encodeURIComponent(start)}`
|
|
26
|
+
: "/api/history/period";
|
|
27
|
+
const qs = buildQS({
|
|
28
|
+
filter_entity_id: entity_id,
|
|
29
|
+
end_time: end,
|
|
30
|
+
minimal_response: minimal_response ? true : undefined,
|
|
31
|
+
significant_changes_only: significant_changes_only ? true : undefined,
|
|
32
|
+
no_attributes: no_attributes ? true : undefined,
|
|
33
|
+
});
|
|
34
|
+
return ok(await client.fetch(`${base}${qs}`));
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return err(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
server.tool("get_logbook", "Get logbook entries — human-readable events such as state changes and automation triggers (GET /api/logbook). Useful for seeing what happened and when.", {
|
|
41
|
+
entity_id: z.string().optional().describe("Limit to a single entity"),
|
|
42
|
+
start: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("ISO 8601 start timestamp. Defaults to 1 day ago on the HA side."),
|
|
46
|
+
end: z.string().optional().describe("ISO 8601 end timestamp"),
|
|
47
|
+
}, async ({ entity_id, start, end }) => {
|
|
48
|
+
try {
|
|
49
|
+
const base = start ? `/api/logbook/${encodeURIComponent(start)}` : "/api/logbook";
|
|
50
|
+
const qs = buildQS({ entity: entity_id, end_time: end });
|
|
51
|
+
return ok(await client.fetch(`${base}${qs}`));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return err(e);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err, domainOf } from "../hass/format.js";
|
|
3
|
+
export function registerRegistryTools(server, ws) {
|
|
4
|
+
server.tool("list_registry_entities", "List ALL registered entities from the entity registry via the WebSocket API — including disabled and currently-unavailable entities that GET /api/states (list_entities) omits. Includes name, platform, area_id, device_id, entity_category and disabled_by. Optionally filter by domain.", {
|
|
5
|
+
domain: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe("Only entities in this domain, e.g. 'light', 'sensor'"),
|
|
9
|
+
include_disabled: z.boolean().optional().describe("Include disabled entities (default true)"),
|
|
10
|
+
}, async ({ domain, include_disabled }) => {
|
|
11
|
+
try {
|
|
12
|
+
const list = (await ws.command("config/entity_registry/list"));
|
|
13
|
+
let rows = list;
|
|
14
|
+
if (domain)
|
|
15
|
+
rows = rows.filter((e) => domainOf(e.entity_id) === domain);
|
|
16
|
+
if (include_disabled === false)
|
|
17
|
+
rows = rows.filter((e) => !e.disabled_by);
|
|
18
|
+
const entities = rows.map((e) => ({
|
|
19
|
+
entity_id: e.entity_id,
|
|
20
|
+
name: e.name ?? e.original_name ?? null,
|
|
21
|
+
platform: e.platform ?? null,
|
|
22
|
+
area_id: e.area_id ?? null,
|
|
23
|
+
device_id: e.device_id ?? null,
|
|
24
|
+
entity_category: e.entity_category ?? null,
|
|
25
|
+
disabled_by: e.disabled_by ?? null,
|
|
26
|
+
hidden_by: e.hidden_by ?? null,
|
|
27
|
+
}));
|
|
28
|
+
return ok({ count: entities.length, entities });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return err(e);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
server.tool("list_devices", "List devices from the device registry via the WebSocket API (id, name, manufacturer, model, area_id). Useful for grouping entities by physical device when writing automations.", {}, async () => {
|
|
35
|
+
try {
|
|
36
|
+
const list = (await ws.command("config/device_registry/list"));
|
|
37
|
+
const devices = list.map((d) => ({
|
|
38
|
+
id: d.id,
|
|
39
|
+
name: d.name_by_user ?? d.name ?? null,
|
|
40
|
+
manufacturer: d.manufacturer ?? null,
|
|
41
|
+
model: d.model ?? null,
|
|
42
|
+
area_id: d.area_id ?? null,
|
|
43
|
+
}));
|
|
44
|
+
return ok({ count: devices.length, devices });
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return err(e);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.tool("list_areas", "List areas from the area registry via the WebSocket API (area_id, name, floor_id, icon). Areas group devices and entities by room or location.", {}, async () => {
|
|
51
|
+
try {
|
|
52
|
+
return ok(await ws.command("config/area_registry/list"));
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return err(e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
server.tool("list_labels", "List labels from the label registry via the WebSocket API (label_id, name, color, icon). Labels are cross-cutting tags applied to entities, devices and areas.", {}, async () => {
|
|
59
|
+
try {
|
|
60
|
+
return ok(await ws.command("config/label_registry/list"));
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return err(e);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err } from "../hass/format.js";
|
|
3
|
+
const RELOAD_TARGETS = {
|
|
4
|
+
all: { domain: "homeassistant", service: "reload_all" },
|
|
5
|
+
core: { domain: "homeassistant", service: "reload_core_config" },
|
|
6
|
+
automation: { domain: "automation", service: "reload" },
|
|
7
|
+
script: { domain: "script", service: "reload" },
|
|
8
|
+
scene: { domain: "scene", service: "reload" },
|
|
9
|
+
template: { domain: "template", service: "reload" },
|
|
10
|
+
group: { domain: "group", service: "reload" },
|
|
11
|
+
zone: { domain: "zone", service: "reload" },
|
|
12
|
+
person: { domain: "person", service: "reload" },
|
|
13
|
+
timer: { domain: "timer", service: "reload" },
|
|
14
|
+
schedule: { domain: "schedule", service: "reload" },
|
|
15
|
+
counter: { domain: "counter", service: "reload" },
|
|
16
|
+
input_boolean: { domain: "input_boolean", service: "reload" },
|
|
17
|
+
input_number: { domain: "input_number", service: "reload" },
|
|
18
|
+
input_select: { domain: "input_select", service: "reload" },
|
|
19
|
+
input_text: { domain: "input_text", service: "reload" },
|
|
20
|
+
input_datetime: { domain: "input_datetime", service: "reload" },
|
|
21
|
+
input_button: { domain: "input_button", service: "reload" },
|
|
22
|
+
rest_command: { domain: "rest_command", service: "reload" },
|
|
23
|
+
command_line: { domain: "command_line", service: "reload" },
|
|
24
|
+
mqtt: { domain: "mqtt", service: "reload" },
|
|
25
|
+
};
|
|
26
|
+
const targets = Object.keys(RELOAD_TARGETS);
|
|
27
|
+
export function registerReloadTools(server, client) {
|
|
28
|
+
server.tool("reload", "Reload a reloadable Home Assistant domain so YAML edits take effect without a full restart. Restricted to a fixed allowlist of safe reload services — it cannot control devices. Use 'all' (homeassistant.reload_all) to reload everything reloadable, or 'core' (homeassistant.reload_core_config) for core config like customize. Run check_config first.", {
|
|
29
|
+
target: z.enum(targets).describe(`What to reload. One of: ${targets.join(", ")}`),
|
|
30
|
+
}, async ({ target }) => {
|
|
31
|
+
try {
|
|
32
|
+
const { domain, service } = RELOAD_TARGETS[target];
|
|
33
|
+
const result = await client.fetch(`/api/services/${domain}/${service}`, { method: "POST" });
|
|
34
|
+
return ok({ reloaded: target, service: `${domain}.${service}`, result });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return err(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err } from "../hass/format.js";
|
|
3
|
+
export function registerServiceTools(server, client) {
|
|
4
|
+
server.tool("list_services", "List services that can be called — the actions available in automations and scripts. With no domain, returns a compact map of domain -> service names. Pass a domain to get full detail (description, fields, target, selectors) for that domain's services.", {
|
|
5
|
+
domain: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe("Only this domain, e.g. 'light', 'notify', 'automation'. Returns full field/target detail when set."),
|
|
9
|
+
}, async ({ domain }) => {
|
|
10
|
+
try {
|
|
11
|
+
const data = (await client.fetch("/api/services"));
|
|
12
|
+
if (domain) {
|
|
13
|
+
const entry = data.find((d) => d.domain === domain);
|
|
14
|
+
return ok(entry ?? { domain, services: {} });
|
|
15
|
+
}
|
|
16
|
+
const domains = data
|
|
17
|
+
.map((d) => ({ domain: d.domain, services: Object.keys(d.services ?? {}) }))
|
|
18
|
+
.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
19
|
+
return ok({ count: domains.length, domains });
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
return err(e);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
server.tool("list_events", "List the event types the instance is currently listening for, with listener counts. Useful for discovering events to use as automation triggers (the 'event' trigger platform).", {}, async () => {
|
|
26
|
+
try {
|
|
27
|
+
return ok(await client.fetch("/api/events"));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
return err(e);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err } from "../hass/format.js";
|
|
3
|
+
export function registerSystemTools(server, client) {
|
|
4
|
+
server.tool("get_config", "Get the running Home Assistant configuration: version, location, unit system, time zone, currency, config directory, and the list of loaded components/integrations. Also serves as a connectivity/token check.", {}, async () => {
|
|
5
|
+
try {
|
|
6
|
+
return ok(await client.fetch("/api/config"));
|
|
7
|
+
}
|
|
8
|
+
catch (e) {
|
|
9
|
+
return err(e);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
server.tool("check_config", "Validate the Home Assistant YAML configuration currently on the server (POST /api/config/core/check_config). Returns { result: 'valid' | 'invalid', errors }. Requires the 'config' integration (included in default_config). Run this after editing YAML and before reloading or restarting.", {}, async () => {
|
|
13
|
+
try {
|
|
14
|
+
return ok(await client.fetch("/api/config/core/check_config", { method: "POST" }));
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return err(e);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
server.tool("get_error_log", "Get the Home Assistant error log as plain text. Returns the most recent lines (default 100). Useful for diagnosing why a config or automation failed to load.", {
|
|
21
|
+
lines: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Number of trailing lines to return (default 100; use 0 for the full log)"),
|
|
26
|
+
}, async ({ lines }) => {
|
|
27
|
+
try {
|
|
28
|
+
const text = await client.fetchText("/api/error_log");
|
|
29
|
+
const n = lines ?? 100;
|
|
30
|
+
if (n > 0) {
|
|
31
|
+
return ok(text.split("\n").slice(-n).join("\n"));
|
|
32
|
+
}
|
|
33
|
+
return ok(text);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return err(e);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err } from "../hass/format.js";
|
|
3
|
+
export function registerTemplateTools(server, client) {
|
|
4
|
+
server.tool("render_template", "Render a Home Assistant Jinja2 template against live state and return the result as text (POST /api/template). Use this to develop and debug template sensors, template conditions, and automation templates, e.g. \"{{ states('sensor.temperature') | float > 20 }}\". Read-only: HA renders without side effects.", {
|
|
5
|
+
template: z
|
|
6
|
+
.string()
|
|
7
|
+
.describe("Jinja2 template string, e.g. \"{{ states('sensor.temperature') }}\""),
|
|
8
|
+
variables: z
|
|
9
|
+
.record(z.unknown())
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Optional variables made available to the template"),
|
|
12
|
+
}, async ({ template, variables }) => {
|
|
13
|
+
try {
|
|
14
|
+
const body = JSON.stringify(variables ? { template, variables } : { template });
|
|
15
|
+
return ok(await client.fetchText("/api/template", { method: "POST", body }));
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
return err(e);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orellbuehler/homeassistant-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for Home Assistant: introspect entities, services, events, registries, render templates and validate configuration to help AI agents author HA configs and automations.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"homeassistant-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"!dist/__tests__"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"lint": "eslint src",
|
|
20
|
+
"format": "prettier --write .",
|
|
21
|
+
"format:check": "prettier --check .",
|
|
22
|
+
"prepare": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"home-assistant",
|
|
31
|
+
"homeassistant",
|
|
32
|
+
"hass",
|
|
33
|
+
"home-automation",
|
|
34
|
+
"llm",
|
|
35
|
+
"ai"
|
|
36
|
+
],
|
|
37
|
+
"author": "Orell Bühler",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/OrellBuehler/homeassistant-mcp.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/OrellBuehler/homeassistant-mcp#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/OrellBuehler/homeassistant-mcp/issues"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
52
|
+
"ws": "^8.18.0",
|
|
53
|
+
"zod": "^3.24.4"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@eslint/js": "^10.0.1",
|
|
57
|
+
"@types/node": "^22.15.3",
|
|
58
|
+
"@types/ws": "^8.5.12",
|
|
59
|
+
"eslint": "^10.4.1",
|
|
60
|
+
"eslint-config-prettier": "^10.1.8",
|
|
61
|
+
"prettier": "^3.8.3",
|
|
62
|
+
"typescript": "^5.8.3",
|
|
63
|
+
"typescript-eslint": "^8.60.0",
|
|
64
|
+
"vitest": "^4.1.0"
|
|
65
|
+
}
|
|
66
|
+
}
|