@coffeerunhobby/mcp-ha-connect 2.1.0 → 2.2.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.
Files changed (74) hide show
  1. package/README.md +19 -3
  2. package/dist/config.d.ts +4 -0
  3. package/dist/config.js +6 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/haClient/automations.d.ts +18 -2
  6. package/dist/haClient/automations.js +25 -7
  7. package/dist/haClient/automations.js.map +1 -1
  8. package/dist/haClient/events.d.ts +60 -7
  9. package/dist/haClient/events.js +252 -47
  10. package/dist/haClient/events.js.map +1 -1
  11. package/dist/haClient/index.d.ts +2 -1
  12. package/dist/haClient/index.js +4 -3
  13. package/dist/haClient/index.js.map +1 -1
  14. package/dist/haClient/wsCommand.d.ts +16 -0
  15. package/dist/haClient/wsCommand.js +36 -0
  16. package/dist/haClient/wsCommand.js.map +1 -0
  17. package/dist/omadaClient/site.js +15 -2
  18. package/dist/omadaClient/site.js.map +1 -1
  19. package/dist/ownerQuestions/service.d.ts +167 -0
  20. package/dist/ownerQuestions/service.js +439 -0
  21. package/dist/ownerQuestions/service.js.map +1 -0
  22. package/dist/server/common.d.ts +3 -0
  23. package/dist/server/common.js +2 -1
  24. package/dist/server/common.js.map +1 -1
  25. package/dist/server/http.js +30 -6
  26. package/dist/server/http.js.map +1 -1
  27. package/dist/server/stream.d.ts +2 -0
  28. package/dist/server/stream.js +2 -1
  29. package/dist/server/stream.js.map +1 -1
  30. package/dist/tools/common.d.ts +1 -1
  31. package/dist/tools/common.js +4 -1
  32. package/dist/tools/common.js.map +1 -1
  33. package/dist/tools/homeassistant/askOwner.d.ts +29 -0
  34. package/dist/tools/homeassistant/askOwner.js +85 -0
  35. package/dist/tools/homeassistant/askOwner.js.map +1 -0
  36. package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
  37. package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
  38. package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
  39. package/dist/tools/index.d.ts +3 -0
  40. package/dist/tools/index.js +5 -1
  41. package/dist/tools/index.js.map +1 -1
  42. package/dist/utils/plainText.d.ts +22 -0
  43. package/dist/utils/plainText.js +39 -0
  44. package/dist/utils/plainText.js.map +1 -0
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CHANGELOG.md +19 -0
  48. package/openapi.json +1 -1
  49. package/package.json +1 -1
  50. package/src/config.ts +10 -0
  51. package/src/haClient/automations.ts +39 -7
  52. package/src/haClient/events.ts +289 -56
  53. package/src/haClient/index.ts +10 -4
  54. package/src/haClient/wsCommand.ts +44 -0
  55. package/src/omadaClient/site.ts +17 -2
  56. package/src/ownerQuestions/service.ts +536 -0
  57. package/src/server/common.ts +5 -1
  58. package/src/server/http.ts +30 -6
  59. package/src/server/stream.ts +4 -1
  60. package/src/tools/common.ts +4 -1
  61. package/src/tools/homeassistant/askOwner.ts +133 -0
  62. package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
  63. package/src/tools/index.ts +8 -1
  64. package/src/utils/plainText.ts +48 -0
  65. package/src/version.ts +1 -1
  66. package/tests/config.test.ts +12 -0
  67. package/tests/haClient/automationTrace.test.ts +59 -0
  68. package/tests/haClient/automations.test.ts +16 -0
  69. package/tests/haClient/events.test.ts +455 -0
  70. package/tests/haClient/eventsTls.test.ts +52 -0
  71. package/tests/omadaClient/ntp.test.ts +15 -0
  72. package/tests/ownerQuestions/service.test.ts +499 -0
  73. package/tests/ownerQuestions/tools.test.ts +186 -0
  74. package/tests/tools/homeassistant/handlers.test.ts +10 -1
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Owner question tools: askOwner / getOwnerAnswer / cancelOwnerQuestion.
3
+ * See src/ownerQuestions/service.ts for the design.
4
+ */
5
+
6
+ import type { McpServer } from '@modelcontextprotocol/server';
7
+ import { z } from 'zod';
8
+
9
+ import { LIMITS, type OwnerQuestionService } from '../../ownerQuestions/service.js';
10
+ import { validatePlainText } from '../../utils/plainText.js';
11
+ import { toToolResult, wrapToolHandler, Permission, type ToolExtra } from '../common.js';
12
+
13
+ const plainText = (field: string, maxLength: number, allowNewlines = false) =>
14
+ z.string().transform((value, ctx) => {
15
+ try {
16
+ return validatePlainText(value, { field, maxLength, allowNewlines });
17
+ } catch (error) {
18
+ ctx.addIssue({ code: 'custom', message: error instanceof Error ? error.message : String(error) });
19
+ return z.NEVER;
20
+ }
21
+ });
22
+
23
+ export const askOwnerSchema = z.object({
24
+ question: plainText('question', LIMITS.questionChars, true).describe(
25
+ `The question, plain text only (no emoji), at most ${LIMITS.questionChars} characters; longer text is refused, not cut`
26
+ ),
27
+ title: plainText('title', LIMITS.titleChars).optional().describe(`Notification title, at most ${LIMITS.titleChars} characters (default "Approval needed")`),
28
+ buttons: z
29
+ .array(plainText('button label', LIMITS.buttonChars))
30
+ .min(1)
31
+ .max(LIMITS.maxButtons)
32
+ .refine((labels) => new Set(labels.map((l) => l.toLowerCase())).size === labels.length, 'Button labels must be unique')
33
+ .optional()
34
+ .describe('1-3 button labels; default ["Approve", "Deny"]. The answer reports the tapped index (0 = first).'),
35
+ target: z
36
+ .string()
37
+ .regex(/^mobile_app_[a-z0-9_]{1,64}$/, 'target must be a mobile app notify service, e.g. "mobile_app_pixel_7"')
38
+ .describe('The phone to ask: its notify service name, e.g. "mobile_app_pixel_7"'),
39
+ timeoutSeconds: z
40
+ .number()
41
+ .int()
42
+ .min(LIMITS.minTimeoutSeconds)
43
+ .max(LIMITS.maxTimeoutSeconds)
44
+ .optional()
45
+ .describe(`How long the question stays open (default ${LIMITS.defaultTimeoutSeconds} s, max 7 days). No answer by then = timeout, treat as NO.`),
46
+ channel: plainText('channel', 40).optional().describe('Android notification channel name'),
47
+ importance: z.enum(['high', 'default', 'low', 'min']).optional().describe('Android notification channel importance'),
48
+ });
49
+
50
+ const requestIdSchema = z.string().min(1).max(4096).describe('The requestId returned by askOwner');
51
+
52
+ export const getOwnerAnswerSchema = z.object({
53
+ requestId: requestIdSchema,
54
+ waitSeconds: z
55
+ .number()
56
+ .int()
57
+ .min(0)
58
+ .max(LIMITS.maxWaitSeconds)
59
+ .optional()
60
+ .describe(`Wait up to this many seconds for an answer before returning "pending" (default 25, max ${LIMITS.maxWaitSeconds})`),
61
+ });
62
+
63
+ export const cancelOwnerQuestionSchema = z.object({ requestId: requestIdSchema });
64
+
65
+ /** The token `sub` of the caller; stdio (local trust) has none. */
66
+ function callerOf(extra: ToolExtra): string {
67
+ return extra.http?.authInfo?.clientId ?? 'local';
68
+ }
69
+
70
+ export function registerOwnerQuestionTools(server: McpServer, service: OwnerQuestionService): number {
71
+ server.registerTool(
72
+ 'askOwner',
73
+ {
74
+ description:
75
+ "Ask the owner a question on their phone with up to 3 buttons (default Approve / Deny) and return immediately with a requestId. " +
76
+ 'Then call getOwnerAnswer(requestId) until the status is not "pending". ' +
77
+ 'Anything except an explicit approving answer must be treated as NO: deny, timeout, cancelled and unknown. ' +
78
+ 'The owner may be away from the phone for hours; use a timeout that fits.',
79
+ inputSchema: askOwnerSchema,
80
+ },
81
+ wrapToolHandler(
82
+ 'askOwner',
83
+ async (args: z.infer<typeof askOwnerSchema>, extra: ToolExtra) =>
84
+ toToolResult(
85
+ await service.ask(
86
+ {
87
+ question: args.question,
88
+ title: args.title ?? 'Approval needed',
89
+ buttons: args.buttons ?? ['Approve', 'Deny'],
90
+ target: args.target,
91
+ timeoutSeconds: args.timeoutSeconds ?? LIMITS.defaultTimeoutSeconds,
92
+ channel: args.channel,
93
+ importance: args.importance,
94
+ },
95
+ callerOf(extra)
96
+ )
97
+ ),
98
+ Permission.NOTIFY
99
+ )
100
+ );
101
+
102
+ server.registerTool(
103
+ 'getOwnerAnswer',
104
+ {
105
+ description:
106
+ 'Get the answer to an askOwner question, waiting up to waitSeconds. Status: "answered" (with index, button, answeredAt, answeredBy), ' +
107
+ '"pending" (ask again), "timeout", "cancelled" or "unknown". Only the client that asked can read the answer.',
108
+ inputSchema: getOwnerAnswerSchema,
109
+ },
110
+ wrapToolHandler(
111
+ 'getOwnerAnswer',
112
+ async (args: z.infer<typeof getOwnerAnswerSchema>, extra: ToolExtra) =>
113
+ toToolResult(await service.getAnswer(args.requestId, callerOf(extra), args.waitSeconds ?? 25)),
114
+ Permission.NOTIFY
115
+ )
116
+ );
117
+
118
+ server.registerTool(
119
+ 'cancelOwnerQuestion',
120
+ {
121
+ description: 'Withdraw an open askOwner question; the phone notification changes to "Cancelled". Returns the final status.',
122
+ inputSchema: cancelOwnerQuestionSchema,
123
+ },
124
+ wrapToolHandler(
125
+ 'cancelOwnerQuestion',
126
+ async (args: z.infer<typeof cancelOwnerQuestionSchema>, extra: ToolExtra) =>
127
+ toToolResult(await service.cancel(args.requestId, callerOf(extra))),
128
+ Permission.NOTIFY
129
+ )
130
+ );
131
+
132
+ return 3;
133
+ }
@@ -3,22 +3,28 @@
3
3
  */
4
4
 
5
5
  import type { McpServer } from '@modelcontextprotocol/server';
6
+ import { z } from 'zod';
6
7
  import type { HaClient } from '../../haClient/index.js';
7
8
  import { entityIdSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
8
- import type { z } from 'zod';
9
9
 
10
- type EntityIdArgs = z.infer<typeof entityIdSchema>;
10
+ export const getAutomationTraceSchema = entityIdSchema.extend({
11
+ limit: z.number().int().min(1).max(20).optional().describe('How many of the most recent runs to return (default 5, max 20)'),
12
+ });
13
+
14
+ type GetAutomationTraceArgs = z.infer<typeof getAutomationTraceSchema>;
11
15
 
12
16
  export function registerGetAutomationTraceTool(server: McpServer, client: HaClient): void {
13
17
  server.registerTool(
14
18
  'getAutomationTrace',
15
19
  {
16
- description: 'Get the execution trace (history) of an automation. Shows when it was triggered and what actions were executed.',
17
- inputSchema: entityIdSchema,
20
+ description:
21
+ 'Get the most recent runs of an automation, newest first: when it ran, what triggered it, how it ended (state, last step, error). ' +
22
+ 'Only automations with an id (created in the UI, or YAML with an id) have traces.',
23
+ inputSchema: getAutomationTraceSchema,
18
24
  },
19
- wrapToolHandler('getAutomationTrace', async ({ entity_id }: EntityIdArgs) => {
20
- const trace = await client.getAutomationTrace(entity_id);
21
- return toToolResult({ entity_id, trace_count: Array.isArray(trace) ? trace.length : 0, traces: trace });
25
+ wrapToolHandler('getAutomationTrace', async ({ entity_id, limit }: GetAutomationTraceArgs) => {
26
+ const traces = await client.getAutomationTrace(entity_id, limit);
27
+ return toToolResult({ entity_id, trace_count: traces.length, traces });
22
28
  }, Permission.QUERY)
23
29
  );
24
30
  }
@@ -13,6 +13,8 @@ import { registerHomeAssistantTools } from './homeassistant/index.js';
13
13
  import { registerOmadaTools, type OmadaRegistrationMode } from './omada/index.js';
14
14
  import { registerAITools } from './ai/index.js';
15
15
  import { registerInfraTools, type RestAction } from './infra/index.js';
16
+ import { registerOwnerQuestionTools } from './homeassistant/askOwner.js';
17
+ import type { OwnerQuestionService } from '../ownerQuestions/service.js';
16
18
 
17
19
  export interface RegisterToolsOptions {
18
20
  server: McpServer;
@@ -23,13 +25,15 @@ export interface RegisterToolsOptions {
23
25
  restActions?: Record<string, RestAction>;
24
26
  /** Tool registration strategy for the Omada plugin (default 'eager'). */
25
27
  toolRegistrationMode?: OmadaRegistrationMode;
28
+ /** Owner questions (askOwner & co.); registered only when the service is running. */
29
+ ownerQuestions?: OwnerQuestionService;
26
30
  }
27
31
 
28
32
  /**
29
33
  * Register all available tools based on configured clients
30
34
  */
31
35
  export function registerAllTools(options: RegisterToolsOptions): void {
32
- const { server, haClient, omadaClient, aiClient, restActions, toolRegistrationMode } = options;
36
+ const { server, haClient, omadaClient, aiClient, restActions, toolRegistrationMode, ownerQuestions } = options;
33
37
  logger.debug('Registering all tools');
34
38
 
35
39
  let totalTools = 0;
@@ -38,6 +42,9 @@ export function registerAllTools(options: RegisterToolsOptions): void {
38
42
  if (haClient) {
39
43
  const haToolCount = registerHomeAssistantTools(server, haClient);
40
44
  totalTools += haToolCount;
45
+ if (ownerQuestions) {
46
+ totalTools += registerOwnerQuestionTools(server, ownerQuestions);
47
+ }
41
48
  }
42
49
 
43
50
  // Omada tools (if client provided)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Plain-text validation for user-visible strings we put on the owner's phone.
3
+ *
4
+ * Text is NFC-normalized first (so composed letters such as Romanian ș/ț/ă count
5
+ * as one character), then every character must be a letter, digit, punctuation,
6
+ * a math/currency/modifier symbol, the degree sign, or a plain space; line
7
+ * breaks only where allowed. Everything else is refused, never stripped:
8
+ * emoji and pictographs, control and invisible format characters (zero-width
9
+ * joiners, bidi overrides that make text display differently than it reads),
10
+ * variation selectors and leftover combining marks ("zalgo"), private-use and
11
+ * lone surrogates.
12
+ */
13
+
14
+ const ALLOWED_CHAR = /^[\p{L}\p{N}\p{P}\p{Sm}\p{Sc}\p{Sk}° ]$/u;
15
+
16
+ export interface PlainTextOptions {
17
+ field: string;
18
+ maxLength: number;
19
+ allowNewlines?: boolean;
20
+ }
21
+
22
+ /**
23
+ * @returns the NFC-normalized text
24
+ * @throws Error naming the field and the first offending character
25
+ */
26
+ export function validatePlainText(text: string, options: PlainTextOptions): string {
27
+ const normalized = text.normalize('NFC');
28
+ const chars = [...normalized];
29
+ if (chars.length === 0 || normalized.trim() === '') {
30
+ throw new Error(`${options.field} must not be empty`);
31
+ }
32
+ if (chars.length > options.maxLength) {
33
+ throw new Error(`${options.field} is ${chars.length} characters; the maximum is ${options.maxLength}`);
34
+ }
35
+ for (const [index, char] of chars.entries()) {
36
+ if (char === '\n' && options.allowNewlines) {
37
+ continue;
38
+ }
39
+ if (!ALLOWED_CHAR.test(char)) {
40
+ const codePoint = char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0');
41
+ throw new Error(
42
+ `${options.field} contains a disallowed character U+${codePoint} at position ${index + 1}; ` +
43
+ 'only plain text is accepted (no emoji, control or invisible characters)'
44
+ );
45
+ }
46
+ }
47
+ return normalized;
48
+ }
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '2.1.0';
5
+ export const VERSION = '2.2.0';
@@ -37,6 +37,18 @@ describe('loadConfig', () => {
37
37
  expect(config.stateful).toBe(false);
38
38
  });
39
39
 
40
+ it('parses owner question settings (enabled by default; HA users as a trimmed list)', () => {
41
+ const base = { HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123', HA_TOKEN: 't' };
42
+
43
+ const defaults = loadConfig(base);
44
+ expect(defaults.ownerQuestionsEnabled).toBe(true);
45
+ expect(defaults.ownerQuestionsHaUsers).toEqual([]);
46
+
47
+ const set = loadConfig({ ...base, MCP_OWNER_QUESTIONS_ENABLED: 'false', MCP_OWNER_QUESTIONS_HA_USERS: ' a1b2 , c3d4 ,' });
48
+ expect(set.ownerQuestionsEnabled).toBe(false);
49
+ expect(set.ownerQuestionsHaUsers).toEqual(['a1b2', 'c3d4']);
50
+ });
51
+
40
52
  it('should apply default values for optional fields', () => {
41
53
  const env = {
42
54
  HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
@@ -0,0 +1,59 @@
1
+ /**
2
+ * getAutomationTrace goes over the WebSocket (`trace/list`), keyed by the
3
+ * automation's config id — there is no REST trace endpoint in Home Assistant.
4
+ */
5
+
6
+ import { describe, it, expect, vi } from 'vitest';
7
+
8
+ import { AutomationOperations } from '../../src/haClient/automations.js';
9
+ import type { StateOperations } from '../../src/haClient/states.js';
10
+
11
+ function setup(state: unknown, traces: unknown = []) {
12
+ const stateOps = { getState: vi.fn().mockResolvedValue(state) } as unknown as StateOperations;
13
+ const wsCommand = vi.fn().mockResolvedValue(traces);
14
+ const request = { get: vi.fn() };
15
+ const ops = new AutomationOperations(stateOps, undefined, request as never, wsCommand as never);
16
+ return { ops, wsCommand, request };
17
+ }
18
+
19
+ const automation = (attributes: Record<string, unknown>) => ({ entity_id: 'automation.relay', state: 'on', attributes });
20
+
21
+ const run = (id: string, start: string) => ({ run_id: id, timestamp: { start, finish: start }, trigger: 'state of sensor.x' });
22
+
23
+ describe('AutomationOperations.getAutomationTrace', () => {
24
+ it('lists traces by the automation id attribute, newest first, over the WebSocket only', async () => {
25
+ const { ops, wsCommand, request } = setup(automation({ id: '1700000000000' }), [
26
+ run('a', '2026-10-07T08:00:00Z'),
27
+ run('c', '2026-10-07T10:00:00Z'),
28
+ run('b', '2026-10-07T09:00:00Z'),
29
+ ]);
30
+
31
+ const traces = await ops.getAutomationTrace('automation.relay');
32
+
33
+ expect(wsCommand).toHaveBeenCalledWith({ type: 'trace/list', domain: 'automation', item_id: '1700000000000' });
34
+ expect(traces.map((t) => t.run_id)).toEqual(['c', 'b', 'a']);
35
+ expect(request.get).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it('returns at most `limit` runs (default 5)', async () => {
39
+ const many = Array.from({ length: 8 }, (_, i) => run(String(i), `2026-10-07T0${i}:00:00Z`));
40
+ const { ops } = setup(automation({ id: 7 }), many);
41
+
42
+ expect(await ops.getAutomationTrace('automation.relay')).toHaveLength(5);
43
+ expect((await ops.getAutomationTrace('automation.relay', 2)).map((t) => t.run_id)).toEqual(['7', '6']);
44
+ });
45
+
46
+ it('explains that a YAML automation without an id has no traces', async () => {
47
+ const { ops, wsCommand } = setup(automation({ friendly_name: 'Relay' }));
48
+
49
+ await expect(ops.getAutomationTrace('automation.relay')).rejects.toThrow(/has no 'id'.*no traces/);
50
+ expect(wsCommand).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('reports an unknown automation', async () => {
54
+ const { ops, wsCommand } = setup(null);
55
+
56
+ await expect(ops.getAutomationTrace('automation.nope')).rejects.toThrow('Automation automation.nope not found');
57
+ expect(wsCommand).not.toHaveBeenCalled();
58
+ });
59
+ });
@@ -131,3 +131,19 @@ describe('AutomationOperations', () => {
131
131
  });
132
132
  });
133
133
  });
134
+
135
+ describe('AutomationOperations.createAutomation path encoding', () => {
136
+ it('encodes the automation id in the URL path but keeps it unchanged in the body', async () => {
137
+ const post = vi.fn().mockResolvedValue({ result: 'ok' });
138
+ const serviceOps = { callService: vi.fn().mockResolvedValue([]) };
139
+ const ops = new AutomationOperations(
140
+ { getState: vi.fn() } as unknown as StateOperations,
141
+ serviceOps as never,
142
+ { post, get: vi.fn() } as never
143
+ );
144
+
145
+ await ops.createAutomation({ id: 'room/a?b', alias: 'Test', trigger: [], action: [] } as never);
146
+
147
+ expect(post).toHaveBeenCalledWith('/config/automation/config/room%2Fa%3Fb', expect.objectContaining({ id: 'room/a?b' }));
148
+ });
149
+ });