@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.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 (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. package/vitest.config.ts +0 -38
@@ -1,57 +0,0 @@
1
- /**
2
- * MCP_CHAT_TOOLS slice parsing — standalone module with zero imports so both
3
- * config.ts and the chat-face binding registry can use it without import cycles.
4
- *
5
- * Syntax (operator UX modeled on category:permission slicing):
6
- * MCP_CHAT_TOOLS=ha-core:rw,ha-history:r,omada-read:r
7
- *
8
- * Unset -> undefined -> the chat face serves exactly the legacy 8 tools
9
- * (pre-v1.7 behavior). Set -> the slice governs which chat-ELIGIBLE bindings
10
- * are active; it can only narrow what the code marks eligible, never widen.
11
- */
12
-
13
- /** Chat-face tool categories. Deliberately few — this is a curated surface. */
14
- export const CHAT_CATEGORIES = [
15
- 'ha-core', // states, search, summaries, version + callService (write)
16
- 'ha-history', // entity history
17
- 'ha-automations', // list automations
18
- 'omada-read', // omada_browse / omada_read (resource graph, read-only)
19
- 'omada-write', // reversible network writes (SSID on/off)
20
- ] as const;
21
-
22
- export type ChatCategory = (typeof CHAT_CATEGORIES)[number];
23
- export type ChatAccess = 'read' | 'write';
24
-
25
- /** Operator-selected slice: category -> allowed access levels. */
26
- export type ChatToolsSlice = Map<ChatCategory, Set<ChatAccess>>;
27
-
28
- /**
29
- * Parse `MCP_CHAT_TOOLS`. Returns `undefined` when unset/blank (legacy default).
30
- * Throws on malformed input so a config typo fails loudly at startup (same
31
- * philosophy as parseRestActions).
32
- */
33
- export function parseChatTools(raw: string | undefined): ChatToolsSlice | undefined {
34
- if (!raw || raw.trim() === '') {
35
- return undefined;
36
- }
37
-
38
- const slice: ChatToolsSlice = new Map();
39
- for (const part of raw.split(',').map((p) => p.trim()).filter(Boolean)) {
40
- const [name, access] = part.split(':').map((p) => p.trim());
41
- if (!(CHAT_CATEGORIES as readonly string[]).includes(name)) {
42
- throw new Error(
43
- `Invalid MCP_CHAT_TOOLS category '${name}'. Known categories: ${CHAT_CATEGORIES.join(', ')}`
44
- );
45
- }
46
- if (!access || !/^(r|w|rw)$/.test(access)) {
47
- throw new Error(
48
- `Invalid MCP_CHAT_TOOLS access '${access ?? ''}' for '${name}': use r, w, or rw (e.g. "${name}:r")`
49
- );
50
- }
51
- const levels = slice.get(name as ChatCategory) ?? new Set<ChatAccess>();
52
- if (access.includes('r')) levels.add('read');
53
- if (access.includes('w')) levels.add('write');
54
- slice.set(name as ChatCategory, levels);
55
- }
56
- return slice;
57
- }
@@ -1,170 +0,0 @@
1
- /**
2
- * Common MCP server creation
3
- * Shared across all transport types (stdio, SSE, stream)
4
- */
5
-
6
- import { McpServer } from '@modelcontextprotocol/server';
7
- import type { RegisteredTool } from '@modelcontextprotocol/server';
8
- import type { HaClient } from '../haClient/index.js';
9
- import type { LocalAIClient } from '../localAI/index.js';
10
- import type { OmadaClient } from '../omadaClient/index.js';
11
- import { registerAllTools } from '../tools/index.js';
12
- import { isToolVisibleTo } from '../tools/common.js';
13
- import type { OmadaRegistrationMode } from '../tools/omada/index.js';
14
- import type { RestAction } from '../tools/infra/index.js';
15
- import { registerAllResources } from '../resources/index.js';
16
- import { generateInstructions } from './instructions.js';
17
- import { logger } from '../utils/logger.js';
18
- import type { OwnerQuestionService } from '../ownerQuestions/service.js';
19
- import { VERSION } from '../version.js';
20
-
21
- export interface CreateServerOptions {
22
- haClient?: HaClient;
23
- omadaClient?: OmadaClient;
24
- aiClient?: LocalAIClient;
25
- /** Pre-registered REST actions for invokeAction (empty/undefined = tool not registered). */
26
- restActions?: Record<string, RestAction>;
27
- /** Tool registration strategy for the Omada plugin (default 'eager'). */
28
- toolRegistrationMode?: OmadaRegistrationMode;
29
- /**
30
- * The caller's permission mask. When set, tools whose static permission the
31
- * caller lacks are disabled, which hides them from `tools/list` (and the SDK
32
- * rejects calls to them). Undefined = no filtering (stdio local trust).
33
- * Execution-time RBAC in wrapToolHandler still applies either way.
34
- */
35
- callerPermissions?: number;
36
- /** Owner questions service (askOwner & co.), when running. */
37
- ownerQuestions?: OwnerQuestionService;
38
- }
39
-
40
- export function createServer(options: CreateServerOptions): McpServer {
41
- const { haClient, omadaClient, aiClient, restActions, toolRegistrationMode, callerPermissions, ownerQuestions } = options;
42
- logger.debug('Creating MCP server instance');
43
-
44
- // Generate instructions based on enabled plugins
45
- const instructions = generateInstructions({
46
- haEnabled: !!haClient,
47
- omadaEnabled: !!omadaClient,
48
- aiEnabled: !!aiClient,
49
- });
50
-
51
- const server = new McpServer(
52
- {
53
- name: 'mcp-ha-connect',
54
- version: VERSION,
55
- },
56
- {
57
- instructions,
58
- }
59
- );
60
-
61
- logger.debug('Server instructions generated', { length: instructions.length });
62
-
63
- // Register all tools based on configured clients, capturing each RegisteredTool
64
- // handle so tools the caller can never use can be hidden afterwards.
65
- const registeredTools = new Map<string, RegisteredTool>();
66
- const originalRegisterTool = server.registerTool;
67
- server.registerTool = ((...args: Parameters<McpServer['registerTool']>) => {
68
- const config = args[1] as { inputSchema?: unknown; outputSchema?: unknown } | undefined;
69
- memoizeJsonSchema(config?.inputSchema);
70
- memoizeJsonSchema(config?.outputSchema);
71
- const tool = (originalRegisterTool as (...a: unknown[]) => RegisteredTool).apply(server, args);
72
- registeredTools.set(args[0], tool);
73
- return tool;
74
- }) as McpServer['registerTool'];
75
- try {
76
- registerAllTools({
77
- server,
78
- haClient,
79
- omadaClient,
80
- aiClient,
81
- restActions,
82
- toolRegistrationMode,
83
- ownerQuestions,
84
- });
85
- } finally {
86
- server.registerTool = originalRegisterTool;
87
- }
88
-
89
- if (callerPermissions !== undefined) {
90
- const hidden = hideUnpermittedTools(registeredTools, callerPermissions);
91
- logger.debug('Filtered tools by caller permissions', { visible: registeredTools.size - hidden, hidden });
92
- }
93
-
94
- // Register Home Assistant resources (if HA client provided)
95
- if (haClient) {
96
- registerAllResources(server, haClient);
97
- }
98
-
99
- logger.debug('MCP server instance created');
100
-
101
- return server;
102
- }
103
-
104
- /**
105
- * Disable every tool the caller cannot use (it lacks the tool's static permission, or the
106
- * tool's visibility rule — e.g. omada_read's per-path one — finds nothing usable). Disabled
107
- * tools are omitted from `tools/list`, so an LLM client only plans with tools it can use.
108
- * Returns the number of tools hidden.
109
- */
110
- export function hideUnpermittedTools(tools: Map<string, RegisteredTool>, callerPermissions: number): number {
111
- let hidden = 0;
112
- for (const [name, tool] of tools) {
113
- if (!isToolVisibleTo(name, callerPermissions)) {
114
- tool.disable();
115
- hidden++;
116
- }
117
- }
118
- return hidden;
119
- }
120
-
121
- /** Schemas whose JSON Schema conversion is already memoized (process lifetime). */
122
- const memoizedSchemas = new WeakSet<object>();
123
-
124
- type JsonSchemaConverter = (options?: unknown) => unknown;
125
-
126
- /** Whether `schema`'s JSON Schema conversion has been memoized (for tests/diagnostics). */
127
- export function isJsonSchemaMemoized(schema: unknown): boolean {
128
- return schema !== null && typeof schema === 'object' && memoizedSchemas.has(schema);
129
- }
130
-
131
- /**
132
- * Convert a tool schema to JSON Schema once per process instead of once per request.
133
- *
134
- * MCP SDK v2 converts every tool's schema to JSON Schema eagerly inside
135
- * `registerTool`, through the schema's Standard Schema `~standard.jsonSchema`
136
- * provider. The server is stateless and registers every tool on every request, so
137
- * that conversion (zod's `toJSONSchema`) became about half of request setup time. Tool
138
- * schemas are module-level constants, so the result never changes: this shadows the
139
- * provider on the schema instance with a cached one (the SDK's documented hook for a
140
- * custom provider). Each call returns a deep clone, so a caller that mutates the
141
- * result can never corrupt the cache. Validation (`~standard.validate`) is untouched.
142
- */
143
- export function memoizeJsonSchema(schema: unknown): void {
144
- if (schema === null || typeof schema !== 'object' || memoizedSchemas.has(schema)) {
145
- return;
146
- }
147
- memoizedSchemas.add(schema);
148
- const std = (schema as { '~standard'?: { jsonSchema?: { input?: JsonSchemaConverter; output?: JsonSchemaConverter } } })[
149
- '~standard'
150
- ];
151
- if (!std?.jsonSchema?.input || !std.jsonSchema.output) {
152
- return; // Not a converter-carrying Standard Schema: leave it to the SDK as-is.
153
- }
154
- const memo = (convert: JsonSchemaConverter): JsonSchemaConverter => {
155
- const cache = new Map<string, unknown>();
156
- return (options) => {
157
- const key = JSON.stringify(options ?? null);
158
- if (!cache.has(key)) {
159
- cache.set(key, convert(options));
160
- }
161
- return structuredClone(cache.get(key));
162
- };
163
- };
164
- Object.defineProperty(schema, '~standard', {
165
- value: { ...std, jsonSchema: { input: memo(std.jsonSchema.input), output: memo(std.jsonSchema.output) } },
166
- configurable: true,
167
- enumerable: false,
168
- writable: false,
169
- });
170
- }
@@ -1,343 +0,0 @@
1
- /**
2
- * SSE Event Subscription Endpoint
3
- * Allows HTTP clients to subscribe to Home Assistant events via Server-Sent Events
4
- */
5
-
6
- import type { IncomingMessage, ServerResponse } from 'node:http';
7
- import { randomUUID } from 'node:crypto';
8
- import type { EventSubscriber, HaEvent } from '../haClient/events.js';
9
- import { logger } from '../utils/logger.js';
10
- import { sanitizeError } from '../utils/sanitizeError.js';
11
- import { Permission, hasPermission } from '../permissions/index.js';
12
- import type { AuthenticatedRequest } from './auth.js';
13
-
14
- export interface SSEClient {
15
- id: string;
16
- res: ServerResponse;
17
- subscriptionIds: string[];
18
- domain?: string;
19
- entityId?: string;
20
- eventTypes?: string[];
21
- createdAt: Date;
22
- lastEventAt?: Date;
23
- eventCount: number;
24
- }
25
-
26
- // Store active SSE clients
27
- const clients = new Map<string, SSEClient>();
28
-
29
- /**
30
- * Generate an unguessable SSE client ID (L5).
31
- *
32
- * Uses a CSPRNG (randomUUID) rather than Math.random()/Date.now(), so a client
33
- * ID cannot be predicted or enumerated by an observer who knows roughly when a
34
- * connection was opened.
35
- */
36
- export function generateClientId(): string {
37
- return randomUUID();
38
- }
39
-
40
- /**
41
- * Parse query parameters from URL
42
- */
43
- function parseQueryParams(url: string): URLSearchParams {
44
- const queryIndex = url.indexOf('?');
45
- if (queryIndex === -1) {
46
- return new URLSearchParams();
47
- }
48
- return new URLSearchParams(url.slice(queryIndex + 1));
49
- }
50
-
51
- /**
52
- * Send SSE event to a client
53
- */
54
- function sendSSEEvent(res: ServerResponse, event: string, data: unknown): void {
55
- if (res.writableEnded) {
56
- return;
57
- }
58
-
59
- const payload = typeof data === 'string' ? data : JSON.stringify(data);
60
- res.write(`event: ${event}\n`);
61
- res.write(`data: ${payload}\n\n`);
62
- }
63
-
64
- /**
65
- * Send SSE comment (keep-alive)
66
- */
67
- function sendSSEComment(res: ServerResponse, comment: string): void {
68
- if (res.writableEnded) {
69
- return;
70
- }
71
- res.write(`: ${comment}\n\n`);
72
- }
73
-
74
- /**
75
- * Handle SSE subscription request
76
- */
77
- export async function handleEventSubscription(
78
- req: IncomingMessage,
79
- res: ServerResponse,
80
- eventSubscriber: EventSubscriber
81
- ): Promise<void> {
82
- // L2: the SSE stream reads entity state — require QUERY, like every other read.
83
- // Auth middleware has already populated req.auth; fail closed if the mask is absent.
84
- const userPermissions = ((req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined) ?? 0;
85
- if (!hasPermission(userPermissions, Permission.QUERY)) {
86
- logger.warn('SSE subscription denied: missing QUERY permission');
87
- res.writeHead(403, { 'Content-Type': 'application/json' });
88
- res.end(JSON.stringify({ error: 'Forbidden', message: 'SSE event subscription requires QUERY permission' }));
89
- return;
90
- }
91
-
92
- const clientId = generateClientId();
93
- const params = parseQueryParams(req.url ?? '');
94
-
95
- // Parse filter parameters
96
- const domain = params.get('domain') ?? undefined;
97
- const entityId = params.get('entity_id') ?? undefined;
98
- const eventTypesParam = params.get('event_types');
99
- const eventTypes = eventTypesParam ? eventTypesParam.split(',').map(t => t.trim()) : undefined;
100
-
101
- logger.info('New SSE subscription request', {
102
- clientId,
103
- domain,
104
- entityId,
105
- eventTypes,
106
- });
107
-
108
- // Set SSE headers
109
- res.writeHead(200, {
110
- 'Content-Type': 'text/event-stream',
111
- 'Cache-Control': 'no-cache',
112
- 'Connection': 'keep-alive',
113
- 'Access-Control-Allow-Origin': req.headers.origin ?? '*',
114
- 'Access-Control-Allow-Credentials': 'true',
115
- 'X-Accel-Buffering': 'no', // Disable nginx buffering
116
- });
117
-
118
- // Create client record
119
- const client: SSEClient = {
120
- id: clientId,
121
- res,
122
- subscriptionIds: [],
123
- domain,
124
- entityId,
125
- eventTypes,
126
- createdAt: new Date(),
127
- eventCount: 0,
128
- };
129
- clients.set(clientId, client);
130
-
131
- // Send initial connection event
132
- sendSSEEvent(res, 'connected', {
133
- client_id: clientId,
134
- message: 'Connected to Home Assistant event stream',
135
- filters: { domain, entityId, eventTypes },
136
- timestamp: new Date().toISOString(),
137
- });
138
-
139
- // Ensure event subscriber is connected
140
- if (!eventSubscriber.isConnected()) {
141
- try {
142
- await eventSubscriber.connect();
143
- } catch (error) {
144
- logger.error('Failed to connect event subscriber', { error });
145
- // M6: log detail above; the SSE client gets a generic message only.
146
- sendSSEEvent(res, 'error', {
147
- error: 'Failed to connect to Home Assistant',
148
- message: sanitizeError(error),
149
- });
150
- res.end();
151
- clients.delete(clientId);
152
- return;
153
- }
154
- }
155
-
156
- // Create event callback
157
- const eventCallback = (event: HaEvent) => {
158
- // Apply filters
159
- if (eventTypes && !eventTypes.includes(event.event_type)) {
160
- return;
161
- }
162
-
163
- if (domain && event.event_type === 'state_changed') {
164
- const eventDomain = event.data.entity_id?.split('.')[0];
165
- if (eventDomain !== domain) {
166
- return;
167
- }
168
- }
169
-
170
- if (entityId && event.data.entity_id !== entityId) {
171
- return;
172
- }
173
-
174
- // Update client stats
175
- client.lastEventAt = new Date();
176
- client.eventCount++;
177
-
178
- // Send event to client
179
- sendSSEEvent(res, event.event_type, {
180
- event_type: event.event_type,
181
- entity_id: event.data.entity_id,
182
- old_state: event.data.old_state,
183
- new_state: event.data.new_state,
184
- domain: event.data.domain,
185
- service: event.data.service,
186
- service_data: event.data.service_data,
187
- time_fired: event.time_fired,
188
- context: event.context,
189
- });
190
- };
191
-
192
- // Subscribe to events based on filters
193
- try {
194
- let subscriptionId: string;
195
-
196
- if (entityId) {
197
- // Subscribe to specific entity
198
- subscriptionId = await eventSubscriber.subscribeEntity(entityId, eventCallback);
199
- } else if (domain) {
200
- // Subscribe to domain
201
- subscriptionId = await eventSubscriber.subscribeDomain(domain, eventCallback);
202
- } else if (eventTypes && eventTypes.length === 1) {
203
- // Subscribe to specific event type
204
- subscriptionId = await eventSubscriber.subscribeEventType(eventTypes[0], eventCallback);
205
- } else {
206
- // Subscribe to all state_changed events by default
207
- subscriptionId = await eventSubscriber.subscribeEventType('state_changed', eventCallback);
208
- }
209
-
210
- client.subscriptionIds.push(subscriptionId);
211
-
212
- logger.info('SSE client subscribed to events', {
213
- clientId,
214
- subscriptionId,
215
- });
216
- } catch (error) {
217
- logger.error('Failed to subscribe to events', { error, clientId });
218
- // M6: log detail above; the SSE client gets a generic message only.
219
- sendSSEEvent(res, 'error', {
220
- error: 'Failed to subscribe to events',
221
- message: sanitizeError(error),
222
- });
223
- }
224
-
225
- // Keep-alive ping every 30 seconds
226
- const keepAliveInterval = setInterval(() => {
227
- if (res.writableEnded) {
228
- clearInterval(keepAliveInterval);
229
- return;
230
- }
231
- sendSSEComment(res, `keepalive ${new Date().toISOString()}`);
232
- }, 30000);
233
-
234
- // Handle client disconnect
235
- req.on('close', () => {
236
- clearInterval(keepAliveInterval);
237
-
238
- // Unsubscribe from all events
239
- for (const subId of client.subscriptionIds) {
240
- eventSubscriber.unsubscribe(subId).catch(error => {
241
- logger.error('Failed to unsubscribe', { error, subscriptionId: subId });
242
- });
243
- }
244
-
245
- clients.delete(clientId);
246
-
247
- logger.info('SSE client disconnected', {
248
- clientId,
249
- eventCount: client.eventCount,
250
- duration: Date.now() - client.createdAt.getTime(),
251
- });
252
- });
253
-
254
- // Handle errors
255
- req.on('error', (error) => {
256
- logger.error('SSE request error', { error, clientId });
257
- clearInterval(keepAliveInterval);
258
- clients.delete(clientId);
259
- });
260
- }
261
-
262
- /**
263
- * Get list of active SSE clients
264
- */
265
- export function getActiveClients(): Array<{
266
- id: string;
267
- domain?: string;
268
- entityId?: string;
269
- eventTypes?: string[];
270
- createdAt: Date;
271
- lastEventAt?: Date;
272
- eventCount: number;
273
- }> {
274
- return Array.from(clients.values()).map(client => ({
275
- id: client.id,
276
- domain: client.domain,
277
- entityId: client.entityId,
278
- eventTypes: client.eventTypes,
279
- createdAt: client.createdAt,
280
- lastEventAt: client.lastEventAt,
281
- eventCount: client.eventCount,
282
- }));
283
- }
284
-
285
- /**
286
- * Get count of active SSE clients
287
- */
288
- export function getClientCount(): number {
289
- return clients.size;
290
- }
291
-
292
- /**
293
- * Broadcast event to all matching clients
294
- */
295
- export function broadcastEvent(event: string, data: unknown, filter?: {
296
- domain?: string;
297
- entityId?: string;
298
- }): void {
299
- for (const client of clients.values()) {
300
- // Apply filter
301
- if (filter?.domain && client.domain !== filter.domain) {
302
- continue;
303
- }
304
- if (filter?.entityId && client.entityId !== filter.entityId) {
305
- continue;
306
- }
307
-
308
- sendSSEEvent(client.res, event, data);
309
- client.eventCount++;
310
- client.lastEventAt = new Date();
311
- }
312
- }
313
-
314
- /**
315
- * Disconnect a specific client
316
- */
317
- export function disconnectClient(clientId: string): boolean {
318
- const client = clients.get(clientId);
319
- if (!client) {
320
- return false;
321
- }
322
-
323
- sendSSEEvent(client.res, 'disconnect', { reason: 'Server initiated disconnect' });
324
- client.res.end();
325
- clients.delete(clientId);
326
-
327
- return true;
328
- }
329
-
330
- /**
331
- * Disconnect all clients
332
- */
333
- export function disconnectAllClients(): number {
334
- const count = clients.size;
335
-
336
- for (const client of clients.values()) {
337
- sendSSEEvent(client.res, 'disconnect', { reason: 'Server shutdown' });
338
- client.res.end();
339
- }
340
-
341
- clients.clear();
342
- return count;
343
- }