@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.
- package/README.md +23 -5
- package/dist/config.d.ts +4 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/haClient/automations.d.ts +18 -2
- package/dist/haClient/automations.js +25 -7
- package/dist/haClient/automations.js.map +1 -1
- package/dist/haClient/events.d.ts +60 -7
- package/dist/haClient/events.js +252 -47
- package/dist/haClient/events.js.map +1 -1
- package/dist/haClient/index.d.ts +2 -1
- package/dist/haClient/index.js +4 -3
- package/dist/haClient/index.js.map +1 -1
- package/dist/haClient/wsCommand.d.ts +16 -0
- package/dist/haClient/wsCommand.js +36 -0
- package/dist/haClient/wsCommand.js.map +1 -0
- package/dist/omadaClient/client.d.ts +33 -1
- package/dist/omadaClient/client.js +61 -2
- package/dist/omadaClient/client.js.map +1 -1
- package/dist/omadaClient/index.d.ts +7 -0
- package/dist/omadaClient/index.js +6 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/request.d.ts +5 -0
- package/dist/omadaClient/request.js +11 -2
- package/dist/omadaClient/request.js.map +1 -1
- package/dist/ownerQuestions/service.d.ts +167 -0
- package/dist/ownerQuestions/service.js +439 -0
- package/dist/ownerQuestions/service.js.map +1 -0
- package/dist/server/common.d.ts +3 -0
- package/dist/server/common.js +2 -1
- package/dist/server/common.js.map +1 -1
- package/dist/server/http.js +30 -6
- package/dist/server/http.js.map +1 -1
- package/dist/server/stream.d.ts +2 -0
- package/dist/server/stream.js +2 -1
- package/dist/server/stream.js.map +1 -1
- package/dist/tools/common.d.ts +1 -1
- package/dist/tools/common.js +9 -2
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/homeassistant/askOwner.d.ts +29 -0
- package/dist/tools/homeassistant/askOwner.js +85 -0
- package/dist/tools/homeassistant/askOwner.js.map +1 -0
- package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
- package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
- package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +5 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/omada/blockClient.js +3 -1
- package/dist/tools/omada/blockClient.js.map +1 -1
- package/dist/tools/omada/deleteClient.d.ts +3 -0
- package/dist/tools/omada/deleteClient.js +15 -0
- package/dist/tools/omada/deleteClient.js.map +1 -0
- package/dist/tools/omada/index.js +7 -2
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/listBlockedClients.d.ts +3 -0
- package/dist/tools/omada/listBlockedClients.js +9 -0
- package/dist/tools/omada/listBlockedClients.js.map +1 -0
- package/dist/tools/omada/namespace.js +17 -0
- package/dist/tools/omada/namespace.js.map +1 -1
- package/dist/utils/plainText.d.ts +22 -0
- package/dist/utils/plainText.js +39 -0
- package/dist/utils/plainText.js.map +1 -0
- package/dist/utils/redact.d.ts +21 -0
- package/dist/utils/redact.js +53 -0
- package/dist/utils/redact.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CHANGELOG.md +24 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +10 -0
- package/src/haClient/automations.ts +39 -7
- package/src/haClient/events.ts +289 -56
- package/src/haClient/index.ts +10 -4
- package/src/haClient/wsCommand.ts +44 -0
- package/src/omadaClient/client.ts +82 -4
- package/src/omadaClient/index.ts +9 -1
- package/src/omadaClient/request.ts +13 -2
- package/src/ownerQuestions/service.ts +536 -0
- package/src/server/common.ts +5 -1
- package/src/server/http.ts +30 -6
- package/src/server/stream.ts +4 -1
- package/src/tools/common.ts +9 -2
- package/src/tools/homeassistant/askOwner.ts +133 -0
- package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
- package/src/tools/index.ts +8 -1
- package/src/tools/omada/blockClient.ts +3 -1
- package/src/tools/omada/deleteClient.ts +27 -0
- package/src/tools/omada/index.ts +7 -2
- package/src/tools/omada/listBlockedClients.ts +17 -0
- package/src/tools/omada/namespace.ts +19 -0
- package/src/utils/plainText.ts +48 -0
- package/src/utils/redact.ts +59 -0
- package/src/version.ts +1 -1
- package/tests/config.test.ts +12 -0
- package/tests/haClient/automationTrace.test.ts +59 -0
- package/tests/haClient/automations.test.ts +16 -0
- package/tests/haClient/events.test.ts +455 -0
- package/tests/haClient/eventsTls.test.ts +52 -0
- package/tests/omadaClient/clientBlocks.test.ts +170 -0
- package/tests/ownerQuestions/service.test.ts +499 -0
- package/tests/ownerQuestions/tools.test.ts +186 -0
- package/tests/tools/homeassistant/handlers.test.ts +10 -1
- package/tests/tools/omada/graph.test.ts +13 -2
- package/tests/utils/redact.test.ts +105 -0
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
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('
|
|
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
|
}
|
package/src/server/stream.ts
CHANGED
|
@@ -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).
|
package/src/tools/common.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
17
|
-
|
|
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 }:
|
|
20
|
-
const
|
|
21
|
-
return toToolResult({ entity_id, trace_count:
|
|
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
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/omada/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 +=
|
|
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
package/tests/config.test.ts
CHANGED
|
@@ -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
|
+
});
|