@orellbuehler/homeassistant-mcp 0.2.0 → 0.4.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/README.md +25 -8
- package/dist/hass/rest.js +23 -15
- package/dist/index.js +7 -1
- package/dist/server.js +3 -1
- package/dist/tools/energy.js +8 -2
- package/dist/tools/registry.js +32 -0
- package/dist/tools/reload.js +1 -1
- package/dist/tools/system.js +1 -0
- package/dist/tools/trace.js +74 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ claude mcp add homeassistant \
|
|
|
31
31
|
|
|
32
32
|
See [Usage with Claude Code](#usage-with-claude-code) for the equivalent JSON config. For any other
|
|
33
33
|
MCP client, run the package directly — `npx -y @orellbuehler/homeassistant-mcp` with `HASS_URL` and
|
|
34
|
-
`HASS_TOKEN` set in the environment. Requires Node.js
|
|
34
|
+
`HASS_TOKEN` set in the environment. Requires Node.js 20+.
|
|
35
35
|
|
|
36
36
|
## Getting a token
|
|
37
37
|
|
|
@@ -120,15 +120,19 @@ available immediately. Verify with `claude mcp list` (should show `homeassistant
|
|
|
120
120
|
|
|
121
121
|
**Registries** (WebSocket)
|
|
122
122
|
|
|
123
|
-
| Tool | Description
|
|
124
|
-
| ------------------------ |
|
|
125
|
-
| `list_registry_entities` | ALL registered entities, incl. disabled/unavailable (area, device, status).
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
123
|
+
| Tool | Description |
|
|
124
|
+
| ------------------------ | ---------------------------------------------------------------------------------------- |
|
|
125
|
+
| `list_registry_entities` | ALL registered entities, incl. disabled/unavailable (area, device, status). |
|
|
126
|
+
| `rename_entity` | Set an entity's registry `name` and/or `new_entity_id` (config edit, no device control). |
|
|
127
|
+
| `list_devices` | Devices (id, name, manufacturer, model, area). |
|
|
128
|
+
| `list_areas` | Areas (area_id, name, floor). |
|
|
129
|
+
| `list_labels` | Labels (label_id, name, color, icon). |
|
|
129
130
|
|
|
130
131
|
`list_entities` (REST) shows entities that currently have state; `list_registry_entities`
|
|
131
132
|
(WebSocket) shows everything registered, including disabled entities, with area/device grouping.
|
|
133
|
+
`rename_entity` writes the registry via `config/entity_registry/update` (requires an admin token):
|
|
134
|
+
`name` overrides the friendly name (null reverts to the integration's `original_name`) and
|
|
135
|
+
`new_entity_id` renames the entity_id within the same domain.
|
|
132
136
|
|
|
133
137
|
**Energy dashboard** (WebSocket)
|
|
134
138
|
|
|
@@ -144,7 +148,20 @@ available immediately. Verify with `claude mcp list` (should show `homeassistant
|
|
|
144
148
|
|
|
145
149
|
The Energy dashboard config lives in `.storage/energy` and is only reachable over WebSocket (not
|
|
146
150
|
REST). The write tools (`save_energy_prefs`, `add_`/`remove_energy_devices`, `set_energy_*`) call
|
|
147
|
-
`energy/save_prefs`, which **requires an admin token**.
|
|
151
|
+
`energy/save_prefs`, which **requires an admin token**. They are read-modify-write (fetch the current
|
|
152
|
+
prefs, then save), so editing the Energy dashboard in the HA UI at the same moment can be overwritten
|
|
153
|
+
— this is fine for the intended single-agent use.
|
|
154
|
+
|
|
155
|
+
**Traces** (WebSocket, read-only)
|
|
156
|
+
|
|
157
|
+
| Tool | Description |
|
|
158
|
+
| -------------------- | ----------------------------------------------------------------------------------- |
|
|
159
|
+
| `list_traces` | Recent execution traces (newest first) for an `automation.*`/`script.*` entity. |
|
|
160
|
+
| `get_trace` | Full step-by-step trace for one run (config, variables, context, error). |
|
|
161
|
+
| `get_trace_contexts` | Map context ids to the trace run that produced them (causality across automations). |
|
|
162
|
+
|
|
163
|
+
Pass an `automation.*` or `script.*` entity id; the trace key is resolved automatically. Use these
|
|
164
|
+
to debug **whether and how** an automation you authored actually ran.
|
|
148
165
|
|
|
149
166
|
## Safety boundary
|
|
150
167
|
|
package/dist/hass/rest.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export class HassClient {
|
|
2
2
|
baseUrl;
|
|
3
3
|
token;
|
|
4
|
-
|
|
4
|
+
timeoutMs;
|
|
5
|
+
constructor(baseUrl, token, timeoutMs = 15000) {
|
|
5
6
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
6
7
|
this.token = token;
|
|
8
|
+
this.timeoutMs = timeoutMs;
|
|
7
9
|
}
|
|
8
10
|
headers(options) {
|
|
9
11
|
return {
|
|
@@ -12,15 +14,29 @@ export class HassClient {
|
|
|
12
14
|
...(options.headers ?? {}),
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
|
-
async
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
async request(path, options) {
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
21
|
+
...options,
|
|
22
|
+
headers: this.headers(options),
|
|
23
|
+
signal: options.signal ?? AbortSignal.timeout(this.timeoutMs),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (e instanceof DOMException && e.name === "TimeoutError") {
|
|
28
|
+
throw new Error(`Home Assistant request timed out after ${this.timeoutMs}ms`, { cause: e });
|
|
29
|
+
}
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
20
32
|
if (!res.ok) {
|
|
21
33
|
const body = await res.text();
|
|
22
34
|
throw new Error(`${res.status} ${res.statusText}: ${body}`);
|
|
23
35
|
}
|
|
36
|
+
return res;
|
|
37
|
+
}
|
|
38
|
+
async fetch(path, options = {}) {
|
|
39
|
+
const res = await this.request(path, options);
|
|
24
40
|
if (res.status === 204)
|
|
25
41
|
return { success: true };
|
|
26
42
|
const ct = res.headers.get("content-type") ?? "";
|
|
@@ -29,14 +45,6 @@ export class HassClient {
|
|
|
29
45
|
return res.text();
|
|
30
46
|
}
|
|
31
47
|
async fetchText(path, options = {}) {
|
|
32
|
-
|
|
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();
|
|
48
|
+
return (await this.request(path, options)).text();
|
|
41
49
|
}
|
|
42
50
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,10 @@ import { client, wsClient } from "./config.js";
|
|
|
4
4
|
import { createServer } from "./server.js";
|
|
5
5
|
const server = createServer(client, wsClient);
|
|
6
6
|
const transport = new StdioServerTransport();
|
|
7
|
-
|
|
7
|
+
try {
|
|
8
|
+
await server.connect(transport);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
console.error(`Failed to start homeassistant-mcp: ${String(e)}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -7,8 +7,9 @@ import { registerHistoryTools } from "./tools/history.js";
|
|
|
7
7
|
import { registerReloadTools } from "./tools/reload.js";
|
|
8
8
|
import { registerRegistryTools } from "./tools/registry.js";
|
|
9
9
|
import { registerEnergyTools } from "./tools/energy.js";
|
|
10
|
+
import { registerTraceTools } from "./tools/trace.js";
|
|
10
11
|
export function createServer(client, wsClient) {
|
|
11
|
-
const server = new McpServer({ name: "homeassistant-mcp", version: "0.
|
|
12
|
+
const server = new McpServer({ name: "homeassistant-mcp", version: "0.4.0" });
|
|
12
13
|
registerEntityTools(server, client);
|
|
13
14
|
registerServiceTools(server, client);
|
|
14
15
|
registerSystemTools(server, client);
|
|
@@ -17,5 +18,6 @@ export function createServer(client, wsClient) {
|
|
|
17
18
|
registerReloadTools(server, client);
|
|
18
19
|
registerRegistryTools(server, wsClient);
|
|
19
20
|
registerEnergyTools(server, wsClient, client);
|
|
21
|
+
registerTraceTools(server, wsClient, client);
|
|
20
22
|
return server;
|
|
21
23
|
}
|
package/dist/tools/energy.js
CHANGED
|
@@ -145,7 +145,7 @@ export function registerEnergyTools(server, ws, client) {
|
|
|
145
145
|
return err(e);
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
|
-
server.tool("validate_energy_prefs", "Run Home Assistant's energy/validate against the current preferences and return per-item issues. device_consumption and energy_sources issues are correlated with their stat_consumption / source so you can see which entity each issue belongs to. Includes the raw validation result.", {}, async () => {
|
|
148
|
+
server.tool("validate_energy_prefs", "Run Home Assistant's energy/validate against the current preferences and return per-item issues. device_consumption, device_consumption_water and energy_sources issues are correlated with their stat_consumption / source so you can see which entity each issue belongs to. Includes the raw validation result.", {}, async () => {
|
|
149
149
|
try {
|
|
150
150
|
const prefs = await getPrefs(ws);
|
|
151
151
|
const result = (await ws.command("energy/validate"));
|
|
@@ -155,10 +155,16 @@ export function registerEnergyTools(server, ws, client) {
|
|
|
155
155
|
issues,
|
|
156
156
|
}))
|
|
157
157
|
.filter((d) => Array.isArray(d.issues) && d.issues.length > 0);
|
|
158
|
+
const device_consumption_water = (result.device_consumption_water ?? [])
|
|
159
|
+
.map((issues, i) => ({
|
|
160
|
+
stat_consumption: prefs.device_consumption_water?.[i]?.stat_consumption ?? null,
|
|
161
|
+
issues,
|
|
162
|
+
}))
|
|
163
|
+
.filter((d) => Array.isArray(d.issues) && d.issues.length > 0);
|
|
158
164
|
const energy_sources = (result.energy_sources ?? [])
|
|
159
165
|
.map((issues, i) => ({ source: prefs.energy_sources?.[i] ?? null, issues }))
|
|
160
166
|
.filter((s) => Array.isArray(s.issues) && s.issues.length > 0);
|
|
161
|
-
return ok({ device_consumption, energy_sources, raw: result });
|
|
167
|
+
return ok({ device_consumption, device_consumption_water, energy_sources, raw: result });
|
|
162
168
|
}
|
|
163
169
|
catch (e) {
|
|
164
170
|
return err(e);
|
package/dist/tools/registry.js
CHANGED
|
@@ -31,6 +31,38 @@ export function registerRegistryTools(server, ws) {
|
|
|
31
31
|
return err(e);
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
|
+
server.tool("rename_entity", "Rename an entity in the entity registry via the WebSocket API (config/entity_registry/update). Set 'name' to override the friendly name (pass null to revert to the integration's original_name) and/or 'new_entity_id' to change the entity_id itself (must keep the same domain). At least one of name or new_entity_id is required. This edits registry config only — it does not control any device. Returns the updated entity_entry.", {
|
|
35
|
+
entity_id: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe("Current entity_id to rename, e.g. 'cover.storen_wohnzimmer_links'"),
|
|
38
|
+
name: z
|
|
39
|
+
.string()
|
|
40
|
+
.nullable()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("New friendly name; null reverts to the integration's original_name"),
|
|
43
|
+
new_entity_id: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("New entity_id; must keep the same domain, e.g. 'cover.wohnzimmer_links'"),
|
|
47
|
+
}, async ({ entity_id, name, new_entity_id }) => {
|
|
48
|
+
try {
|
|
49
|
+
if (name === undefined && new_entity_id === undefined) {
|
|
50
|
+
throw new Error("Provide at least one of 'name' or 'new_entity_id' to rename the entity");
|
|
51
|
+
}
|
|
52
|
+
if (new_entity_id !== undefined && domainOf(new_entity_id) !== domainOf(entity_id)) {
|
|
53
|
+
throw new Error(`new_entity_id must stay in the '${domainOf(entity_id)}' domain (got '${new_entity_id}')`);
|
|
54
|
+
}
|
|
55
|
+
const payload = { entity_id };
|
|
56
|
+
if (name !== undefined)
|
|
57
|
+
payload.name = name;
|
|
58
|
+
if (new_entity_id !== undefined)
|
|
59
|
+
payload.new_entity_id = new_entity_id;
|
|
60
|
+
return ok(await ws.command("config/entity_registry/update", payload));
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return err(e);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
34
66
|
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
67
|
try {
|
|
36
68
|
const list = (await ws.command("config/device_registry/list"));
|
package/dist/tools/reload.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ok, err } from "../hass/format.js";
|
|
3
|
-
const RELOAD_TARGETS = {
|
|
3
|
+
export const RELOAD_TARGETS = {
|
|
4
4
|
all: { domain: "homeassistant", service: "reload_all" },
|
|
5
5
|
core: { domain: "homeassistant", service: "reload_core_config" },
|
|
6
6
|
automation: { domain: "automation", service: "reload" },
|
package/dist/tools/system.js
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err, domainOf } from "../hass/format.js";
|
|
3
|
+
async function resolveTarget(entityId, client) {
|
|
4
|
+
const domain = domainOf(entityId);
|
|
5
|
+
if (domain !== "automation" && domain !== "script") {
|
|
6
|
+
throw new Error(`Traces are only available for automation.* or script.* entities, got ${entityId}`);
|
|
7
|
+
}
|
|
8
|
+
const objectId = entityId.slice(domain.length + 1);
|
|
9
|
+
if (!objectId) {
|
|
10
|
+
throw new Error(`Invalid ${domain} entity id: ${entityId}`);
|
|
11
|
+
}
|
|
12
|
+
if (domain === "script") {
|
|
13
|
+
return { domain, item_id: objectId };
|
|
14
|
+
}
|
|
15
|
+
const state = (await client.fetch(`/api/states/${encodeURIComponent(entityId)}`));
|
|
16
|
+
const id = state.attributes?.id;
|
|
17
|
+
if (id === undefined || id === null || id === "") {
|
|
18
|
+
throw new Error(`Automation ${entityId} has no 'id' attribute, so Home Assistant stores no traces for it`);
|
|
19
|
+
}
|
|
20
|
+
return { domain, item_id: String(id) };
|
|
21
|
+
}
|
|
22
|
+
function startMs(t) {
|
|
23
|
+
const s = t.timestamp?.start;
|
|
24
|
+
const n = s ? Date.parse(s) : 0;
|
|
25
|
+
return Number.isNaN(n) ? 0 : n;
|
|
26
|
+
}
|
|
27
|
+
export function registerTraceTools(server, ws, client) {
|
|
28
|
+
server.tool("list_traces", "List recent execution traces (newest first) for an automation or script via the WebSocket trace API — to debug whether and how it ran. Each summary has run_id, state, script_execution (finished/error/aborted/cancelled/…), last_step, timestamp and any error. Use get_trace with a run_id for the full step-by-step trace.", { entity_id: z.string().describe("An automation.* or script.* entity id") }, async ({ entity_id }) => {
|
|
29
|
+
try {
|
|
30
|
+
const { domain, item_id } = await resolveTarget(entity_id, client);
|
|
31
|
+
const list = ((await ws.command("trace/list", { domain, item_id })) ??
|
|
32
|
+
[]);
|
|
33
|
+
const traces = [...list]
|
|
34
|
+
.sort((a, b) => startMs(b) - startMs(a))
|
|
35
|
+
.map((t) => {
|
|
36
|
+
const row = {
|
|
37
|
+
run_id: t.run_id,
|
|
38
|
+
state: t.state,
|
|
39
|
+
script_execution: t.script_execution,
|
|
40
|
+
last_step: t.last_step,
|
|
41
|
+
timestamp: t.timestamp,
|
|
42
|
+
};
|
|
43
|
+
if (t.error !== undefined)
|
|
44
|
+
row.error = t.error;
|
|
45
|
+
return row;
|
|
46
|
+
});
|
|
47
|
+
return ok({ entity_id, domain, item_id, count: traces.length, traces });
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
return err(e);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
server.tool("get_trace", "Get the full execution trace for one run of an automation or script: the per-step path with each step's result, the config, variables, context and any error. Find run_id with list_traces.", {
|
|
54
|
+
entity_id: z.string().describe("An automation.* or script.* entity id"),
|
|
55
|
+
run_id: z.string().describe("Run id from list_traces"),
|
|
56
|
+
}, async ({ entity_id, run_id }) => {
|
|
57
|
+
try {
|
|
58
|
+
const { domain, item_id } = await resolveTarget(entity_id, client);
|
|
59
|
+
return ok(await ws.command("trace/get", { domain, item_id, run_id }));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return err(e);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
server.tool("get_trace_contexts", "Map Home Assistant context ids to the trace run that produced them for an automation or script (trace/contexts) — useful for tracing causality, e.g. which automation run triggered another.", { entity_id: z.string().describe("An automation.* or script.* entity id") }, async ({ entity_id }) => {
|
|
66
|
+
try {
|
|
67
|
+
const { domain, item_id } = await resolveTarget(entity_id, client);
|
|
68
|
+
return ok(await ws.command("trace/contexts", { domain, item_id }));
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return err(e);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orellbuehler/homeassistant-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"!dist/__tests__"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"build": "tsc",
|
|
14
|
+
"build": "tsc -p tsconfig.build.json",
|
|
15
15
|
"start": "node dist/index.js",
|
|
16
16
|
"test": "vitest run",
|
|
17
17
|
"test:watch": "vitest",
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"lint": "eslint src",
|
|
20
20
|
"format": "prettier --write .",
|
|
21
21
|
"format:check": "prettier --check .",
|
|
22
|
-
"prepare": "npm run build"
|
|
22
|
+
"prepare": "npm run build && prek install"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=
|
|
25
|
+
"node": ">=20"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
28
|
"mcp",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@eslint/js": "^10.0.1",
|
|
57
|
+
"@j178/prek": "^0.4.4",
|
|
57
58
|
"@types/node": "^22.15.3",
|
|
58
59
|
"@types/ws": "^8.5.12",
|
|
59
60
|
"eslint": "^10.4.1",
|