@coffeerunhobby/mcp-ha-connect 2.1.1 → 2.2.1

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 (106) hide show
  1. package/README.md +23 -5
  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/client.d.ts +33 -1
  18. package/dist/omadaClient/client.js +61 -2
  19. package/dist/omadaClient/client.js.map +1 -1
  20. package/dist/omadaClient/index.d.ts +7 -0
  21. package/dist/omadaClient/index.js +6 -0
  22. package/dist/omadaClient/index.js.map +1 -1
  23. package/dist/omadaClient/request.d.ts +5 -0
  24. package/dist/omadaClient/request.js +11 -2
  25. package/dist/omadaClient/request.js.map +1 -1
  26. package/dist/ownerQuestions/service.d.ts +167 -0
  27. package/dist/ownerQuestions/service.js +439 -0
  28. package/dist/ownerQuestions/service.js.map +1 -0
  29. package/dist/server/common.d.ts +3 -0
  30. package/dist/server/common.js +2 -1
  31. package/dist/server/common.js.map +1 -1
  32. package/dist/server/http.js +30 -6
  33. package/dist/server/http.js.map +1 -1
  34. package/dist/server/stream.d.ts +2 -0
  35. package/dist/server/stream.js +2 -1
  36. package/dist/server/stream.js.map +1 -1
  37. package/dist/tools/common.d.ts +1 -1
  38. package/dist/tools/common.js +9 -2
  39. package/dist/tools/common.js.map +1 -1
  40. package/dist/tools/homeassistant/askOwner.d.ts +29 -0
  41. package/dist/tools/homeassistant/askOwner.js +85 -0
  42. package/dist/tools/homeassistant/askOwner.js.map +1 -0
  43. package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
  44. package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
  45. package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
  46. package/dist/tools/index.d.ts +3 -0
  47. package/dist/tools/index.js +5 -1
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/omada/blockClient.js +3 -1
  50. package/dist/tools/omada/blockClient.js.map +1 -1
  51. package/dist/tools/omada/deleteClient.d.ts +3 -0
  52. package/dist/tools/omada/deleteClient.js +15 -0
  53. package/dist/tools/omada/deleteClient.js.map +1 -0
  54. package/dist/tools/omada/index.js +7 -2
  55. package/dist/tools/omada/index.js.map +1 -1
  56. package/dist/tools/omada/listBlockedClients.d.ts +3 -0
  57. package/dist/tools/omada/listBlockedClients.js +9 -0
  58. package/dist/tools/omada/listBlockedClients.js.map +1 -0
  59. package/dist/tools/omada/namespace.js +17 -0
  60. package/dist/tools/omada/namespace.js.map +1 -1
  61. package/dist/utils/plainText.d.ts +22 -0
  62. package/dist/utils/plainText.js +39 -0
  63. package/dist/utils/plainText.js.map +1 -0
  64. package/dist/utils/redact.d.ts +21 -0
  65. package/dist/utils/redact.js +53 -0
  66. package/dist/utils/redact.js.map +1 -0
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/docs/CHANGELOG.md +24 -0
  70. package/openapi.json +1 -1
  71. package/package.json +1 -1
  72. package/src/config.ts +10 -0
  73. package/src/haClient/automations.ts +39 -7
  74. package/src/haClient/events.ts +289 -56
  75. package/src/haClient/index.ts +10 -4
  76. package/src/haClient/wsCommand.ts +44 -0
  77. package/src/omadaClient/client.ts +82 -4
  78. package/src/omadaClient/index.ts +9 -1
  79. package/src/omadaClient/request.ts +13 -2
  80. package/src/ownerQuestions/service.ts +536 -0
  81. package/src/server/common.ts +5 -1
  82. package/src/server/http.ts +30 -6
  83. package/src/server/stream.ts +4 -1
  84. package/src/tools/common.ts +9 -2
  85. package/src/tools/homeassistant/askOwner.ts +133 -0
  86. package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
  87. package/src/tools/index.ts +8 -1
  88. package/src/tools/omada/blockClient.ts +3 -1
  89. package/src/tools/omada/deleteClient.ts +27 -0
  90. package/src/tools/omada/index.ts +7 -2
  91. package/src/tools/omada/listBlockedClients.ts +17 -0
  92. package/src/tools/omada/namespace.ts +19 -0
  93. package/src/utils/plainText.ts +48 -0
  94. package/src/utils/redact.ts +59 -0
  95. package/src/version.ts +1 -1
  96. package/tests/config.test.ts +12 -0
  97. package/tests/haClient/automationTrace.test.ts +59 -0
  98. package/tests/haClient/automations.test.ts +16 -0
  99. package/tests/haClient/events.test.ts +455 -0
  100. package/tests/haClient/eventsTls.test.ts +52 -0
  101. package/tests/omadaClient/clientBlocks.test.ts +170 -0
  102. package/tests/ownerQuestions/service.test.ts +499 -0
  103. package/tests/ownerQuestions/tools.test.ts +186 -0
  104. package/tests/tools/homeassistant/handlers.test.ts +10 -1
  105. package/tests/tools/omada/graph.test.ts +13 -2
  106. package/tests/utils/redact.test.ts +105 -0
@@ -15,6 +15,7 @@ import { handleEventSubscription } from './eventSubscription.js';
15
15
  import { RateLimiter } from './rateLimiter.js';
16
16
  import { createAuthMiddleware } from './auth.js';
17
17
  import { sanitizeError } from '../utils/sanitizeError.js';
18
+ import { OwnerQuestionService } from '../ownerQuestions/service.js';
18
19
  import {
19
20
  LEGACY_BINDINGS,
20
21
  resolveChatBindings,
@@ -343,23 +344,45 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
343
344
  audience: config.authAudience,
344
345
  });
345
346
 
346
- // Initialize event subscriber if SSE events are enabled and HA is configured
347
+ // Initialize the HA event subscriber when SSE events or owner questions need it
347
348
  let eventSubscriber: EventSubscriber | null = null;
348
- if (config.sseEventsEnabled && config.baseUrl && config.token) {
349
+ if ((config.sseEventsEnabled || config.ownerQuestionsEnabled) && config.baseUrl && config.token) {
349
350
  eventSubscriber = new EventSubscriber({
350
351
  baseUrl: config.baseUrl,
351
352
  token: config.token,
353
+ strictSsl: config.strictSsl,
352
354
  });
353
355
 
354
- // Connect to Home Assistant WebSocket
356
+ // Connect to Home Assistant WebSocket. On failure the subscriber keeps
357
+ // retrying in the background; it is kept (not dropped) so events and owner
358
+ // questions start working as soon as Home Assistant is reachable.
355
359
  try {
356
360
  await eventSubscriber.connect();
357
361
  logger.info('Event subscriber connected to Home Assistant');
358
362
  } catch (error) {
359
- logger.warn('Failed to connect event subscriber, SSE events will be unavailable', {
363
+ logger.warn('Home Assistant WebSocket not reachable yet; retrying in the background', {
364
+ error: error instanceof Error ? error.message : String(error),
365
+ });
366
+ }
367
+ }
368
+
369
+ // Owner questions (askOwner & co.) listen for notification taps on the subscriber.
370
+ let ownerQuestions: OwnerQuestionService | undefined;
371
+ if (config.ownerQuestionsEnabled && eventSubscriber && client) {
372
+ const subscriber = eventSubscriber;
373
+ const service = new OwnerQuestionService({
374
+ notify: (target, serviceData) => client.callService({ domain: 'notify', service: target, service_data: serviceData }),
375
+ subscribe: (eventType, callback, onRejected) => subscriber.subscribeEventType(eventType, callback, onRejected),
376
+ secret: config.authSecret,
377
+ allowedUserIds: config.ownerQuestionsHaUsers,
378
+ });
379
+ try {
380
+ await service.start();
381
+ ownerQuestions = service;
382
+ } catch (error) {
383
+ logger.warn('Owner questions unavailable: could not subscribe to notification taps', {
360
384
  error: error instanceof Error ? error.message : String(error),
361
385
  });
362
- eventSubscriber = null;
363
386
  }
364
387
  }
365
388
 
@@ -482,7 +505,7 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
482
505
  const existingState = sessionId ? sessions.get(sessionId) : undefined;
483
506
 
484
507
  const body = req.method !== 'GET' ? await parseBody(req) : undefined;
485
- const streamOptions: StreamTransportOptions = { haClient: client, omadaClient, aiClient, config };
508
+ const streamOptions: StreamTransportOptions = { haClient: client, omadaClient, aiClient, config, ownerQuestions };
486
509
  const state = await handleStreamRequest(streamOptions, req, res, body, existingState);
487
510
 
488
511
  if (state && config.stateful && sessionId) {
@@ -562,6 +585,7 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
562
585
  sessions.clear();
563
586
 
564
587
  // Close event subscriber
588
+ ownerQuestions?.stop();
565
589
  if (eventSubscriber) {
566
590
  eventSubscriber.disconnect();
567
591
  }
@@ -12,6 +12,7 @@ import type { LocalAIClient } from '../localAI/index.js';
12
12
  import type { OmadaClient } from '../omadaClient/index.js';
13
13
  import { logger } from '../utils/logger.js';
14
14
  import { createServer } from './common.js';
15
+ import type { OwnerQuestionService } from '../ownerQuestions/service.js';
15
16
  import type { AuthenticatedRequest } from './auth.js';
16
17
 
17
18
  interface StreamTransportState {
@@ -27,6 +28,7 @@ export interface StreamTransportOptions {
27
28
  omadaClient?: OmadaClient;
28
29
  aiClient?: LocalAIClient;
29
30
  config: EnvironmentConfig;
31
+ ownerQuestions?: OwnerQuestionService;
30
32
  }
31
33
 
32
34
  export interface TransportSecurityOptions {
@@ -94,11 +96,12 @@ export function buildTransportSecurityOptions(config: EnvironmentConfig): Transp
94
96
  * This implements the MCP protocol version 2025-03-26
95
97
  */
96
98
  export function createStreamTransport(options: StreamTransportOptions, callerPermissions?: number): StreamTransportState {
97
- const { haClient, omadaClient, aiClient, config } = options;
99
+ const { haClient, omadaClient, aiClient, config, ownerQuestions } = options;
98
100
  const mcpServer = createServer({
99
101
  haClient,
100
102
  omadaClient,
101
103
  aiClient,
104
+ ownerQuestions,
102
105
  restActions: config.restActions,
103
106
  toolRegistrationMode: config.toolRegistrationMode,
104
107
  // Hide tools this caller can never use from tools/list (RBAC still enforced per call).
@@ -6,6 +6,7 @@
6
6
  import type { CallToolResult, ServerContext } from '@modelcontextprotocol/server';
7
7
  import { z } from 'zod';
8
8
  import { logger } from '../utils/logger.js';
9
+ import { redactSecrets } from '../utils/redact.js';
9
10
  import { Permission, hasPermission, getPermissionNames } from '../permissions/index.js';
10
11
 
11
12
  // Re-export Permission for tool files
@@ -54,7 +55,10 @@ export function getCallerPermissions(extra: ToolExtra): number {
54
55
  * Convert any value to a CallToolResult
55
56
  */
56
57
  export function toToolResult(value: unknown, isError = false): CallToolResult {
57
- const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
58
+ // Every tool response passes here: secrets returned by Home Assistant or Omada
59
+ // (Wi-Fi password, RADIUS/VPN keys, access tokens) never reach the client.
60
+ const safe = redactSecrets(value);
61
+ const text = typeof safe === 'string' ? safe : JSON.stringify(safe, null, 2);
58
62
  return {
59
63
  content: text ? [{ type: 'text' as const, text }] : [],
60
64
  isError,
@@ -147,7 +151,10 @@ export function wrapToolHandler<T>(
147
151
  }, true);
148
152
  }
149
153
 
150
- logger.info('Tool invoked', { tool: name, sessionId, args: safeSerialize(args) });
154
+ // Arguments can carry message text, entity names or MAC addresses: they are
155
+ // logged at debug level only, never in normal (info) operation.
156
+ logger.info('Tool invoked', { tool: name, sessionId });
157
+ logger.debug('Tool arguments', { tool: name, sessionId, args: safeSerialize(args) });
151
158
 
152
159
  try {
153
160
  const result = await handler(args, extra);
@@ -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)
@@ -15,7 +15,9 @@ export function registerBlockClientTool(server: McpServer, client: OmadaClient):
15
15
  {
16
16
  description:
17
17
  'Block a client from the network by MAC address. The client is denied all network access until unblocked. ' +
18
- 'Use omada_listClients first to find the exact MAC. This affects real network connectivity - confirm with the user before applying.',
18
+ 'Use omada_listClients first to find the exact MAC. This affects real network connectivity - confirm with the user before applying. ' +
19
+ "Caution: a blocked device cannot reconnect, and once the controller stops treating it as current, Omada's API can no longer unblock it. " +
20
+ 'For recurring schedules (e.g. a curfew) prefer an Omada time-range access rule on the controller over daily block/unblock calls.',
19
21
  inputSchema: inputSchema,
20
22
  },
21
23
  wrapToolHandler('omada_blockClient', async ({ clientMac, siteId }) =>
@@ -0,0 +1,27 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+
4
+ import type { OmadaClient } from '../../omadaClient/index.js';
5
+ import { toToolResult, wrapToolHandler, Permission } from '../common.js';
6
+
7
+ export function registerDeleteClientTool(server: McpServer, client: OmadaClient): void {
8
+ const inputSchema = z.object({
9
+ clientMac: z.string().min(1, 'clientMac (MAC address) is required'),
10
+ siteId: z.string().min(1).optional(),
11
+ });
12
+
13
+ server.registerTool(
14
+ 'omada_deleteClient',
15
+ {
16
+ description:
17
+ "Delete the controller's record of a client by MAC: its name, history and block state. " +
18
+ 'Use it to clear a block that omada_unblockClient can no longer reach (the device stays refused by the access points although Omada lists nothing). ' +
19
+ 'The device is treated as new when it reconnects. Irreversible for that history - confirm with the user before applying.',
20
+ inputSchema,
21
+ },
22
+ wrapToolHandler('omada_deleteClient', async ({ clientMac, siteId }: z.infer<typeof inputSchema>) =>
23
+ toToolResult(await client.deleteClient(clientMac, siteId)),
24
+ Permission.CONFIGURE
25
+ )
26
+ );
27
+ }
@@ -7,6 +7,8 @@ import type { OmadaClient } from '../../omadaClient/index.js';
7
7
  import { logger } from '../../utils/logger.js';
8
8
 
9
9
  import { registerBlockClientTool } from './blockClient.js';
10
+ import { registerDeleteClientTool } from './deleteClient.js';
11
+ import { registerListBlockedClientsTool } from './listBlockedClients.js';
10
12
  import { registerCyclePoePortTool } from './cyclePoePort.js';
11
13
  import { registerOmadaGraphTools } from './graph.js';
12
14
  import { registerSetSiteNtpServersTool } from './setSiteNtpServers.js';
@@ -91,7 +93,9 @@ export function registerOmadaTools(
91
93
  // Client block tools
92
94
  registerBlockClientTool(server, client);
93
95
  registerUnblockClientTool(server, client);
94
- toolCount += 2;
96
+ registerListBlockedClientsTool(server, client);
97
+ registerDeleteClientTool(server, client);
98
+ toolCount += 4;
95
99
 
96
100
  // Device action tools
97
101
  registerCyclePoePortTool(server, client);
@@ -138,10 +142,11 @@ function registerOmadaToolsGraph(server: McpServer, client: OmadaClient): number
138
142
  registerDisableClientRateLimitTool(server, client);
139
143
  registerBlockClientTool(server, client);
140
144
  registerUnblockClientTool(server, client);
145
+ registerDeleteClientTool(server, client);
141
146
  registerCyclePoePortTool(server, client);
142
147
  registerSetSsidEnabledTool(server, client);
143
148
  registerSetSiteNtpServersTool(server, client);
144
- toolCount += 8;
149
+ toolCount += 9;
145
150
 
146
151
  logger.info('Omada tools registered', { mode: 'graph', toolCount });
147
152
  return toolCount;
@@ -0,0 +1,17 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+
3
+ import type { OmadaClient } from '../../omadaClient/index.js';
4
+ import { siteInputSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
5
+
6
+ export function registerListBlockedClientsTool(server: McpServer, client: OmadaClient): void {
7
+ server.registerTool(
8
+ 'omada_listBlockedClients',
9
+ {
10
+ description:
11
+ 'List clients that are currently blocked, including offline ones (from the known-clients list). ' +
12
+ 'Use it to check block state before or after omada_blockClient / omada_unblockClient.',
13
+ inputSchema: siteInputSchema,
14
+ },
15
+ wrapToolHandler('omada_listBlockedClients', async ({ siteId }) => toToolResult(await client.listBlockedClients(siteId)), Permission.QUERY)
16
+ );
17
+ }
@@ -291,6 +291,25 @@ export const OMADA_RESOURCES: ResourceNode[] = [
291
291
  description: 'Most active clients by traffic.',
292
292
  fetch: (c, a) => c.listMostActiveClients(a.siteId),
293
293
  },
294
+ {
295
+ path: '/clients/known',
296
+ kind: 'collection',
297
+ permission: Q,
298
+ paginated: true,
299
+ defaultPageSize: 50,
300
+ estimatedSize: 'medium',
301
+ description:
302
+ 'Clients the controller remembers, online and offline, with their block state (paginated; use page/pageSize). ' +
303
+ 'Blocked clients have block=true; the controller cannot filter by it, so check every page.',
304
+ fetch: (c, a) =>
305
+ c.readResource({
306
+ pathTemplate: '/sites/{siteId}/insight/clients',
307
+ siteId: a.siteId,
308
+ paginated: true,
309
+ page: a.page,
310
+ pageSize: a.pageSize,
311
+ }),
312
+ },
294
313
  {
295
314
  path: '/clients/activity',
296
315
  kind: 'collection',
@@ -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
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Redaction of secrets in tool responses.
3
+ *
4
+ * Home Assistant and Omada return secrets inside ordinary read results: the
5
+ * Wi-Fi password (Omada `pskSetting.securityKey`), RADIUS / CoA / SNMP secrets,
6
+ * PPSK and VPN keys, portal passwords, camera access tokens in image URLs. A tool
7
+ * response goes to an AI client and from there into transcripts, so every tool
8
+ * response is passed through `redactSecrets` (see toToolResult).
9
+ *
10
+ * Only non-empty string values under secret-looking names are replaced, with
11
+ * "<redacted>": an empty value stays empty (so "is a password set?" is still
12
+ * answerable), and booleans or numbers under similar names (`hidePwd`,
13
+ * `encryptionPsk`) are left alone. `token=` / `access_token=` query values inside
14
+ * URLs are masked as well. The input is never modified.
15
+ *
16
+ * Applied at the response boundary only, never inside the API clients: internal
17
+ * read-modify-write paths (SSID schedule, site NTP) must see real values.
18
+ */
19
+
20
+ export const REDACTED = '<redacted>';
21
+
22
+ /**
23
+ * Secret-looking field names, matched on the whole name or its ending
24
+ * (case-insensitive), from the Omada Open API and Home Assistant responses.
25
+ */
26
+ const SECRET_NAME =
27
+ /(^key$|password|passwd|pwd$|secret|token$|passphrase|presharedkey|privatekey|securitykey|communitystring|^psk$|md5key|simplekey|authcode|devicekey)/i;
28
+
29
+ /** Credentials inside URLs, e.g. /api/camera_proxy/camera.door?token=abc123 */
30
+ const URL_SECRET = /([?&](?:access_token|token|key|password|secret)=)[^&#\s"]+/gi;
31
+
32
+ export function isSecretName(name: string): boolean {
33
+ return SECRET_NAME.test(name);
34
+ }
35
+
36
+ export function redactSecrets<T>(value: T): T {
37
+ return redact(value, new WeakSet()) as T;
38
+ }
39
+
40
+ function redact(value: unknown, seen: WeakSet<object>): unknown {
41
+ if (typeof value === 'string') {
42
+ return value.includes('=') ? value.replace(URL_SECRET, `$1${REDACTED}`) : value;
43
+ }
44
+ if (typeof value !== 'object' || value === null) {
45
+ return value;
46
+ }
47
+ if (seen.has(value)) {
48
+ return '[circular]';
49
+ }
50
+ seen.add(value);
51
+ if (Array.isArray(value)) {
52
+ return value.map((item) => redact(item, seen));
53
+ }
54
+ const out: Record<string, unknown> = {};
55
+ for (const [key, field] of Object.entries(value as Record<string, unknown>)) {
56
+ out[key] = typeof field === 'string' && field !== '' && isSecretName(key) ? REDACTED : redact(field, seen);
57
+ }
58
+ return out;
59
+ }
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.1';
5
+ export const VERSION = '2.2.1';
@@ -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
+ });