@orellbuehler/homeassistant-mcp 0.2.0 → 0.3.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 +15 -2
- 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/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
|
|
|
@@ -144,7 +144,20 @@ available immediately. Verify with `claude mcp list` (should show `homeassistant
|
|
|
144
144
|
|
|
145
145
|
The Energy dashboard config lives in `.storage/energy` and is only reachable over WebSocket (not
|
|
146
146
|
REST). The write tools (`save_energy_prefs`, `add_`/`remove_energy_devices`, `set_energy_*`) call
|
|
147
|
-
`energy/save_prefs`, which **requires an admin token**.
|
|
147
|
+
`energy/save_prefs`, which **requires an admin token**. They are read-modify-write (fetch the current
|
|
148
|
+
prefs, then save), so editing the Energy dashboard in the HA UI at the same moment can be overwritten
|
|
149
|
+
— this is fine for the intended single-agent use.
|
|
150
|
+
|
|
151
|
+
**Traces** (WebSocket, read-only)
|
|
152
|
+
|
|
153
|
+
| Tool | Description |
|
|
154
|
+
| -------------------- | ----------------------------------------------------------------------------------- |
|
|
155
|
+
| `list_traces` | Recent execution traces (newest first) for an `automation.*`/`script.*` entity. |
|
|
156
|
+
| `get_trace` | Full step-by-step trace for one run (config, variables, context, error). |
|
|
157
|
+
| `get_trace_contexts` | Map context ids to the trace run that produced them (causality across automations). |
|
|
158
|
+
|
|
159
|
+
Pass an `automation.*` or `script.*` entity id; the trace key is resolved automatically. Use these
|
|
160
|
+
to debug **whether and how** an automation you authored actually ran.
|
|
148
161
|
|
|
149
162
|
## Safety boundary
|
|
150
163
|
|
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.3.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/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.3.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",
|