@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 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
+ }
@@ -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
+ }