@orellbuehler/homeassistant-mcp 0.1.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 CHANGED
@@ -1,14 +1,20 @@
1
1
  # homeassistant-mcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@orellbuehler/homeassistant-mcp.svg)](https://www.npmjs.com/package/@orellbuehler/homeassistant-mcp)
4
+ [![CI](https://github.com/OrellBuehler/homeassistant-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/OrellBuehler/homeassistant-mcp/actions/workflows/ci.yml)
5
+ [![node](https://img.shields.io/node/v/@orellbuehler/homeassistant-mcp.svg)](https://nodejs.org)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
7
+
3
8
  MCP server for [Home Assistant](https://www.home-assistant.io/) that exposes the
4
9
  [REST API](https://www.home-assistant.io/integrations/api/) and WebSocket registries as tools for
5
10
  AI agents.
6
11
 
7
12
  It is built to **help an agent author and validate Home Assistant configuration and automations** —
8
13
  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**.
14
+ devices), validates the agent's work (render Jinja2 templates, check config, read the error log),
15
+ can reload reloadable domains after YAML edits, and can configure the Energy dashboard (sources and
16
+ the Individual-devices list). It deliberately has **no tools to turn devices on/off, set states, or
17
+ fire events**.
12
18
 
13
19
  ## Install
14
20
 
@@ -23,7 +29,9 @@ claude mcp add homeassistant \
23
29
  -- npx -y @orellbuehler/homeassistant-mcp
24
30
  ```
25
31
 
26
- See [Usage with Claude Code](#usage-with-claude-code) for the equivalent JSON config.
32
+ See [Usage with Claude Code](#usage-with-claude-code) for the equivalent JSON config. For any other
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 20+.
27
35
 
28
36
  ## Getting a token
29
37
 
@@ -122,12 +130,44 @@ available immediately. Verify with `claude mcp list` (should show `homeassistant
122
130
  `list_entities` (REST) shows entities that currently have state; `list_registry_entities`
123
131
  (WebSocket) shows everything registered, including disabled entities, with area/device grouping.
124
132
 
133
+ **Energy dashboard** (WebSocket)
134
+
135
+ | Tool | Description |
136
+ | ------------------------- | ----------------------------------------------------------------------------------- |
137
+ | `get_energy_prefs` | Current Energy dashboard preferences (sources + the Individual-devices list). |
138
+ | `save_energy_prefs` | Overwrite preferences (read-modify-write; replaces each provided key). |
139
+ | `add_energy_devices` | Append entities to Individual devices, deduped, with optional eligibility warnings. |
140
+ | `remove_energy_devices` | Remove entities from Individual devices by `stat_consumption`. |
141
+ | `validate_energy_prefs` | Run `energy/validate` and correlate issues with each entity/source. |
142
+ | `set_energy_grid_source` | Set the single grid source (import/export plus optional cost/price stats). |
143
+ | `set_energy_solar_source` | Add or update a solar production source (upsert by `stat_energy_from`). |
144
+
145
+ The Energy dashboard config lives in `.storage/energy` and is only reachable over WebSocket (not
146
+ REST). The write tools (`save_energy_prefs`, `add_`/`remove_energy_devices`, `set_energy_*`) call
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.
161
+
125
162
  ## Safety boundary
126
163
 
127
164
  - **No device control.** There is no generic `call_service`, no `set_state`, and no `fire_event`.
128
165
  The server cannot turn things on/off.
129
166
  - **`reload` is restricted** to a fixed allowlist of `*.reload` / `homeassistant.reload_*` services.
130
167
  It restarts reloadable domains (e.g. re-reads `automations.yaml`) but cannot control devices.
168
+ - **Energy dashboard config is writable** via `energy/save_prefs` (the `*_energy_*` tools). This is
169
+ configuration authoring — the dashboard's sources and Individual-devices list — not device control,
170
+ and it needs an admin token. No `call_service`, `set_state`, or `fire_event` is added.
131
171
  - **Read tools expose your home's data** (entity names, states, areas) to the agent/LLM. Use a
132
172
  scoped HA user if that matters to you.
133
173
  - Prefer `https` and a trusted network path to your instance.
package/dist/hass/rest.js CHANGED
@@ -1,9 +1,11 @@
1
1
  export class HassClient {
2
2
  baseUrl;
3
3
  token;
4
- constructor(baseUrl, token) {
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 fetch(path, options = {}) {
16
- const res = await fetch(`${this.baseUrl}${path}`, {
17
- ...options,
18
- headers: this.headers(options),
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
- 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();
48
+ return (await this.request(path, options)).text();
41
49
  }
42
50
  }
package/dist/hass/ws.js CHANGED
@@ -56,7 +56,12 @@ export class HassWsClient {
56
56
  finish(() => resolve(msg.result));
57
57
  }
58
58
  else {
59
- finish(() => reject(new Error(`Home Assistant WebSocket error: ${msg.error?.message ?? "unknown error"}`)));
59
+ finish(() => {
60
+ const error = new Error(`Home Assistant WebSocket error: ${msg.error?.message ?? "unknown error"}`);
61
+ if (msg.error?.code)
62
+ error.code = msg.error.code;
63
+ reject(error);
64
+ });
60
65
  }
61
66
  }
62
67
  });
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
- await server.connect(transport);
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
@@ -6,8 +6,10 @@ import { registerTemplateTools } from "./tools/templates.js";
6
6
  import { registerHistoryTools } from "./tools/history.js";
7
7
  import { registerReloadTools } from "./tools/reload.js";
8
8
  import { registerRegistryTools } from "./tools/registry.js";
9
+ import { registerEnergyTools } from "./tools/energy.js";
10
+ import { registerTraceTools } from "./tools/trace.js";
9
11
  export function createServer(client, wsClient) {
10
- const server = new McpServer({ name: "homeassistant-mcp", version: "0.1.0" });
12
+ const server = new McpServer({ name: "homeassistant-mcp", version: "0.3.0" });
11
13
  registerEntityTools(server, client);
12
14
  registerServiceTools(server, client);
13
15
  registerSystemTools(server, client);
@@ -15,5 +17,7 @@ export function createServer(client, wsClient) {
15
17
  registerHistoryTools(server, client);
16
18
  registerReloadTools(server, client);
17
19
  registerRegistryTools(server, wsClient);
20
+ registerEnergyTools(server, wsClient, client);
21
+ registerTraceTools(server, wsClient, client);
18
22
  return server;
19
23
  }
@@ -0,0 +1,235 @@
1
+ import { z } from "zod";
2
+ import { ok, err } from "../hass/format.js";
3
+ const ENERGY_UNITS = new Set([
4
+ "Wh",
5
+ "kWh",
6
+ "MWh",
7
+ "GWh",
8
+ "TWh",
9
+ "mWh",
10
+ "J",
11
+ "kJ",
12
+ "MJ",
13
+ "GJ",
14
+ "cal",
15
+ "kcal",
16
+ "Mcal",
17
+ "Gcal",
18
+ ]);
19
+ const show = (v) => (v === undefined || v === null ? "missing" : String(v));
20
+ async function getPrefs(ws) {
21
+ try {
22
+ return (await ws.command("energy/get_prefs"));
23
+ }
24
+ catch (e) {
25
+ const code = e.code;
26
+ if (code === "ERR_NOT_FOUND" || /no prefs/i.test(String(e))) {
27
+ return { energy_sources: [], device_consumption: [] };
28
+ }
29
+ throw e;
30
+ }
31
+ }
32
+ function eligibilityReasons(state, id) {
33
+ if (!state)
34
+ return [`${id} not found in current states`];
35
+ const a = (state.attributes ?? {});
36
+ const reasons = [];
37
+ if (a.device_class !== "energy") {
38
+ reasons.push(`device_class is ${show(a.device_class)} (expected energy)`);
39
+ }
40
+ if (a.state_class !== "total" && a.state_class !== "total_increasing") {
41
+ reasons.push(`state_class is ${show(a.state_class)} (expected total or total_increasing)`);
42
+ }
43
+ const unit = a.unit_of_measurement;
44
+ if (typeof unit !== "string" || !ENERGY_UNITS.has(unit)) {
45
+ reasons.push(`unit is ${show(unit)} (expected an energy unit such as kWh)`);
46
+ }
47
+ return reasons;
48
+ }
49
+ export function registerEnergyTools(server, ws, client) {
50
+ server.tool("get_energy_prefs", "Get the current Energy dashboard preferences (EnergyPreferences) over the WebSocket API: energy_sources (grid/solar/battery/gas/water) and the device_consumption list shown under 'Individual devices'. Returns an empty object if energy has not been configured yet.", {}, async () => {
51
+ try {
52
+ return ok(await getPrefs(ws));
53
+ }
54
+ catch (e) {
55
+ return err(e);
56
+ }
57
+ });
58
+ server.tool("save_energy_prefs", "Overwrite Energy dashboard preferences via energy/save_prefs (requires an admin token). Each provided key (energy_sources, device_consumption, device_consumption_water) replaces that key server-side; omitted keys are left untouched. Prefer get_energy_prefs first and send back a modified object (read-modify-write). For the common cases use add_energy_devices / remove_energy_devices / set_energy_grid_source / set_energy_solar_source instead.", {
59
+ prefs: z
60
+ .object({
61
+ energy_sources: z.array(z.record(z.unknown())).optional(),
62
+ device_consumption: z.array(z.record(z.unknown())).optional(),
63
+ device_consumption_water: z.array(z.record(z.unknown())).optional(),
64
+ })
65
+ .describe("Partial or full EnergyPreferences object to write"),
66
+ }, async ({ prefs }) => {
67
+ try {
68
+ return ok(await ws.command("energy/save_prefs", prefs));
69
+ }
70
+ catch (e) {
71
+ return err(e);
72
+ }
73
+ });
74
+ server.tool("add_energy_devices", "Append entities to the Energy dashboard 'Individual devices' list (device_consumption), deduping by stat_consumption and preserving energy_sources. Optionally warns about entities that are not valid energy statistics (need device_class energy, state_class total/total_increasing and an energy unit) but still adds them. Returns the added/skipped ids, any warnings, and the resulting device_consumption.", {
75
+ entity_ids: z
76
+ .array(z.string())
77
+ .describe("Entity ids to add as device_consumption stats, e.g. ['sensor.fridge_energy']"),
78
+ names: z
79
+ .array(z.string())
80
+ .optional()
81
+ .describe("Optional display names, aligned by index with entity_ids"),
82
+ }, async ({ entity_ids, names }) => {
83
+ try {
84
+ const prefs = await getPrefs(ws);
85
+ const existing = prefs.device_consumption ?? [];
86
+ const present = new Set(existing.map((d) => d.stat_consumption));
87
+ let statesById = null;
88
+ try {
89
+ const states = (await client.fetch("/api/states"));
90
+ statesById = new Map(states.map((s) => [s.entity_id, s]));
91
+ }
92
+ catch {
93
+ statesById = null;
94
+ }
95
+ const warnings = [];
96
+ if (statesById) {
97
+ for (const id of entity_ids) {
98
+ const reasons = eligibilityReasons(statesById.get(id), id);
99
+ if (reasons.length)
100
+ warnings.push({ entity_id: id, reasons });
101
+ }
102
+ }
103
+ const added = [];
104
+ const skipped = [];
105
+ const next = [...existing];
106
+ entity_ids.forEach((id, i) => {
107
+ if (present.has(id)) {
108
+ skipped.push(id);
109
+ return;
110
+ }
111
+ present.add(id);
112
+ const entry = { stat_consumption: id };
113
+ const name = names?.[i];
114
+ if (name)
115
+ entry.name = name;
116
+ next.push(entry);
117
+ added.push(id);
118
+ });
119
+ const updated = (await ws.command("energy/save_prefs", {
120
+ device_consumption: next,
121
+ }));
122
+ return ok({ added, skipped, warnings, device_consumption: updated.device_consumption });
123
+ }
124
+ catch (e) {
125
+ return err(e);
126
+ }
127
+ });
128
+ server.tool("remove_energy_devices", "Remove entities from the Energy dashboard 'Individual devices' list (device_consumption) by matching stat_consumption, preserving energy_sources. Returns the removed ids and the resulting device_consumption.", {
129
+ entity_ids: z.array(z.string()).describe("Entity ids to remove from device_consumption"),
130
+ }, async ({ entity_ids }) => {
131
+ try {
132
+ const prefs = await getPrefs(ws);
133
+ const remove = new Set(entity_ids);
134
+ const existing = prefs.device_consumption ?? [];
135
+ const removed = existing
136
+ .filter((d) => remove.has(d.stat_consumption))
137
+ .map((d) => d.stat_consumption);
138
+ const next = existing.filter((d) => !remove.has(d.stat_consumption));
139
+ const updated = (await ws.command("energy/save_prefs", {
140
+ device_consumption: next,
141
+ }));
142
+ return ok({ removed, device_consumption: updated.device_consumption });
143
+ }
144
+ catch (e) {
145
+ return err(e);
146
+ }
147
+ });
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
+ try {
150
+ const prefs = await getPrefs(ws);
151
+ const result = (await ws.command("energy/validate"));
152
+ const device_consumption = (result.device_consumption ?? [])
153
+ .map((issues, i) => ({
154
+ stat_consumption: prefs.device_consumption?.[i]?.stat_consumption ?? null,
155
+ issues,
156
+ }))
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);
164
+ const energy_sources = (result.energy_sources ?? [])
165
+ .map((issues, i) => ({ source: prefs.energy_sources?.[i] ?? null, issues }))
166
+ .filter((s) => Array.isArray(s.issues) && s.issues.length > 0);
167
+ return ok({ device_consumption, device_consumption_water, energy_sources, raw: result });
168
+ }
169
+ catch (e) {
170
+ return err(e);
171
+ }
172
+ });
173
+ server.tool("set_energy_grid_source", "Set the single grid source of the Energy dashboard (unified model: import/export stats plus optional cost/price stats), merging onto the existing grid source (unspecified fields are kept) and preserving all other energy_sources and device_consumption. Adds a grid source if none exists; cost_adjustment_day defaults to 0. To clear a field, use save_energy_prefs.", {
174
+ stat_energy_from: z
175
+ .string()
176
+ .optional()
177
+ .describe("Statistic for energy imported from the grid"),
178
+ stat_energy_to: z.string().optional().describe("Statistic for energy returned to the grid"),
179
+ stat_cost: z.string().optional().describe("Statistic tracking import cost"),
180
+ entity_energy_price: z.string().optional().describe("Entity providing the import price"),
181
+ number_energy_price: z.number().optional().describe("Fixed import price per unit"),
182
+ stat_compensation: z.string().optional().describe("Statistic tracking export compensation"),
183
+ entity_energy_price_export: z
184
+ .string()
185
+ .optional()
186
+ .describe("Entity providing the export price"),
187
+ number_energy_price_export: z.number().optional().describe("Fixed export price per unit"),
188
+ cost_adjustment_day: z.number().optional().describe("Daily cost adjustment (default 0)"),
189
+ stat_rate: z.string().optional().describe("Statistic tracking the current grid energy rate"),
190
+ power_config: z
191
+ .object({
192
+ stat_rate: z.string().optional(),
193
+ stat_rate_inverted: z.string().optional(),
194
+ stat_rate_from: z.string().optional(),
195
+ stat_rate_to: z.string().optional(),
196
+ })
197
+ .optional()
198
+ .describe("Live power-flow config; provide exactly one method"),
199
+ }, async (args) => {
200
+ try {
201
+ const prefs = await getPrefs(ws);
202
+ const sources = prefs.energy_sources ?? [];
203
+ const idx = sources.findIndex((s) => s.type === "grid");
204
+ const grid = { ...(idx >= 0 ? sources[idx] : {}), ...args, type: "grid" };
205
+ if (grid.cost_adjustment_day === undefined || grid.cost_adjustment_day === null) {
206
+ grid.cost_adjustment_day = 0;
207
+ }
208
+ const next = idx >= 0 ? sources.map((s, i) => (i === idx ? grid : s)) : [...sources, grid];
209
+ return ok(await ws.command("energy/save_prefs", { energy_sources: next }));
210
+ }
211
+ catch (e) {
212
+ return err(e);
213
+ }
214
+ });
215
+ server.tool("set_energy_solar_source", "Add or update a solar production source on the Energy dashboard, upserting by stat_energy_from (unspecified fields on an existing source are kept) and preserving all other energy_sources and device_consumption.", {
216
+ stat_energy_from: z.string().describe("Statistic for solar energy production"),
217
+ config_entry_solar_forecast: z
218
+ .array(z.string())
219
+ .optional()
220
+ .describe("Config entry ids providing a solar production forecast"),
221
+ stat_rate: z.string().optional().describe("Statistic tracking the current solar energy rate"),
222
+ }, async (args) => {
223
+ try {
224
+ const prefs = await getPrefs(ws);
225
+ const sources = prefs.energy_sources ?? [];
226
+ const idx = sources.findIndex((s) => s.type === "solar" && s.stat_energy_from === args.stat_energy_from);
227
+ const solar = { ...(idx >= 0 ? sources[idx] : {}), ...args, type: "solar" };
228
+ const next = idx >= 0 ? sources.map((s, i) => (i === idx ? solar : s)) : [...sources, solar];
229
+ return ok(await ws.command("energy/save_prefs", { energy_sources: next }));
230
+ }
231
+ catch (e) {
232
+ return err(e);
233
+ }
234
+ });
235
+ }
@@ -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" },
@@ -21,6 +21,7 @@ export function registerSystemTools(server, client) {
21
21
  lines: z
22
22
  .number()
23
23
  .int()
24
+ .min(0)
24
25
  .optional()
25
26
  .describe("Number of trailing lines to return (default 100; use 0 for the full log)"),
26
27
  }, async ({ lines }) => {
@@ -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.1.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": ">=18"
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",