@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.env.example +1 -1
  2. package/README.md +24 -2
  3. package/dist/omadaClient/accessControl.d.ts +290 -0
  4. package/dist/omadaClient/accessControl.js +509 -0
  5. package/dist/omadaClient/accessControl.js.map +1 -0
  6. package/dist/omadaClient/client.d.ts +6 -1
  7. package/dist/omadaClient/client.js +12 -1
  8. package/dist/omadaClient/client.js.map +1 -1
  9. package/dist/omadaClient/diagnose.d.ts +68 -0
  10. package/dist/omadaClient/diagnose.js +164 -0
  11. package/dist/omadaClient/diagnose.js.map +1 -0
  12. package/dist/omadaClient/index.d.ts +9 -0
  13. package/dist/omadaClient/index.js +19 -0
  14. package/dist/omadaClient/index.js.map +1 -1
  15. package/dist/omadaClient/macGroups.d.ts +58 -0
  16. package/dist/omadaClient/macGroups.js +110 -0
  17. package/dist/omadaClient/macGroups.js.map +1 -0
  18. package/dist/omadaClient/serialize.d.ts +13 -0
  19. package/dist/omadaClient/serialize.js +29 -0
  20. package/dist/omadaClient/serialize.js.map +1 -0
  21. package/dist/omadaClient/timeRange.d.ts +41 -0
  22. package/dist/omadaClient/timeRange.js +109 -0
  23. package/dist/omadaClient/timeRange.js.map +1 -0
  24. package/dist/tools/infra/actions.d.ts +1 -1
  25. package/dist/tools/infra/actions.js +1 -1
  26. package/dist/tools/omada/accessControl.d.ts +151 -0
  27. package/dist/tools/omada/accessControl.js +158 -0
  28. package/dist/tools/omada/accessControl.js.map +1 -0
  29. package/dist/tools/omada/index.js +9 -0
  30. package/dist/tools/omada/index.js.map +1 -1
  31. package/dist/tools/omada/macGroups.d.ts +19 -0
  32. package/dist/tools/omada/macGroups.js +77 -0
  33. package/dist/tools/omada/macGroups.js.map +1 -0
  34. package/dist/tools/omada/namespace.js +16 -0
  35. package/dist/tools/omada/namespace.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +5 -1
  39. package/.codex/review-rules.md +0 -16
  40. package/.dockerignore +0 -8
  41. package/.gitattributes +0 -30
  42. package/.github/workflows/ci.yml +0 -193
  43. package/AGENTS.md +0 -389
  44. package/Dockerfile +0 -50
  45. package/docker-compose.yml +0 -23
  46. package/docs/CHANGELOG.md +0 -341
  47. package/docs/COVERAGE.md +0 -219
  48. package/docs/QUICK_START.md +0 -240
  49. package/eslint.config.js +0 -33
  50. package/n8n/ai-agent-safety-monitor.json +0 -355
  51. package/openapi.json +0 -1259
  52. package/scripts/check-version-sync.mjs +0 -69
  53. package/scripts/generate-jwt.ts +0 -121
  54. package/src/config.ts +0 -440
  55. package/src/haClient/automations.ts +0 -261
  56. package/src/haClient/calendars.ts +0 -94
  57. package/src/haClient/config.ts +0 -52
  58. package/src/haClient/devices.ts +0 -653
  59. package/src/haClient/entities.ts +0 -128
  60. package/src/haClient/events.ts +0 -689
  61. package/src/haClient/history.ts +0 -73
  62. package/src/haClient/index.ts +0 -336
  63. package/src/haClient/request.ts +0 -142
  64. package/src/haClient/services.ts +0 -45
  65. package/src/haClient/states.ts +0 -60
  66. package/src/haClient/updates.ts +0 -58
  67. package/src/haClient/wsCommand.ts +0 -44
  68. package/src/index.ts +0 -164
  69. package/src/localAI/index.ts +0 -88
  70. package/src/localAI/prompts.ts +0 -50
  71. package/src/localAI/providers/base.ts +0 -120
  72. package/src/localAI/providers/ollama.ts +0 -78
  73. package/src/localAI/providers/openai.ts +0 -86
  74. package/src/localAI/types.ts +0 -67
  75. package/src/omadaClient/auth.ts +0 -178
  76. package/src/omadaClient/client.ts +0 -337
  77. package/src/omadaClient/device.ts +0 -118
  78. package/src/omadaClient/index.ts +0 -310
  79. package/src/omadaClient/network.ts +0 -284
  80. package/src/omadaClient/request.ts +0 -365
  81. package/src/omadaClient/security.ts +0 -51
  82. package/src/omadaClient/site.ts +0 -344
  83. package/src/ownerQuestions/service.ts +0 -536
  84. package/src/permissions/index.ts +0 -143
  85. package/src/resources/index.ts +0 -213
  86. package/src/server/auth.ts +0 -175
  87. package/src/server/chatFace.ts +0 -789
  88. package/src/server/chatSlice.ts +0 -57
  89. package/src/server/common.ts +0 -170
  90. package/src/server/eventSubscription.ts +0 -343
  91. package/src/server/http.ts +0 -626
  92. package/src/server/instructions.ts +0 -97
  93. package/src/server/rateLimiter.ts +0 -227
  94. package/src/server/stdio.ts +0 -41
  95. package/src/server/stream.ts +0 -205
  96. package/src/tools/ai/analyzeSensors.ts +0 -33
  97. package/src/tools/ai/index.ts +0 -20
  98. package/src/tools/common.ts +0 -442
  99. package/src/tools/homeassistant/askOwner.ts +0 -133
  100. package/src/tools/homeassistant/automationControls.ts +0 -66
  101. package/src/tools/homeassistant/calendar.ts +0 -97
  102. package/src/tools/homeassistant/callService.ts +0 -25
  103. package/src/tools/homeassistant/controlClimate.ts +0 -68
  104. package/src/tools/homeassistant/controlCover.ts +0 -48
  105. package/src/tools/homeassistant/controlFan.ts +0 -44
  106. package/src/tools/homeassistant/controlLight.ts +0 -38
  107. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  108. package/src/tools/homeassistant/createAutomation.ts +0 -35
  109. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  110. package/src/tools/homeassistant/entityAction.ts +0 -35
  111. package/src/tools/homeassistant/entityGuard.ts +0 -28
  112. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  113. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  114. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  115. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  116. package/src/tools/homeassistant/getHistory.ts +0 -24
  117. package/src/tools/homeassistant/getState.ts +0 -24
  118. package/src/tools/homeassistant/getStates.ts +0 -46
  119. package/src/tools/homeassistant/getVersion.ts +0 -21
  120. package/src/tools/homeassistant/index.ts +0 -121
  121. package/src/tools/homeassistant/listAutomations.ts +0 -35
  122. package/src/tools/homeassistant/listEntities.ts +0 -44
  123. package/src/tools/homeassistant/listPersons.ts +0 -46
  124. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  125. package/src/tools/homeassistant/searchEntities.ts +0 -58
  126. package/src/tools/homeassistant/sendNotification.ts +0 -179
  127. package/src/tools/homeassistant/systemTools.ts +0 -52
  128. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  129. package/src/tools/index.ts +0 -78
  130. package/src/tools/infra/actions.ts +0 -95
  131. package/src/tools/infra/index.ts +0 -138
  132. package/src/tools/omada/blockClient.ts +0 -28
  133. package/src/tools/omada/cyclePoePort.ts +0 -32
  134. package/src/tools/omada/deleteClient.ts +0 -27
  135. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  136. package/src/tools/omada/getClient.ts +0 -14
  137. package/src/tools/omada/getDevice.ts +0 -14
  138. package/src/tools/omada/getFirewallSetting.ts +0 -14
  139. package/src/tools/omada/getInternetInfo.ts +0 -14
  140. package/src/tools/omada/getLanNetworkList.ts +0 -14
  141. package/src/tools/omada/getLanProfileList.ts +0 -14
  142. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  143. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  144. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  145. package/src/tools/omada/getSsidDetail.ts +0 -22
  146. package/src/tools/omada/getSsidList.ts +0 -21
  147. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  148. package/src/tools/omada/getThreatList.ts +0 -42
  149. package/src/tools/omada/getWlanGroupList.ts +0 -14
  150. package/src/tools/omada/graph.ts +0 -187
  151. package/src/tools/omada/index.ts +0 -153
  152. package/src/tools/omada/listBlockedClients.ts +0 -17
  153. package/src/tools/omada/listClients.ts +0 -14
  154. package/src/tools/omada/listClientsActivity.ts +0 -25
  155. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  156. package/src/tools/omada/listDevices.ts +0 -14
  157. package/src/tools/omada/listDevicesStats.ts +0 -41
  158. package/src/tools/omada/listMostActiveClients.ts +0 -14
  159. package/src/tools/omada/listSites.ts +0 -18
  160. package/src/tools/omada/namespace.ts +0 -1164
  161. package/src/tools/omada/searchDevices.ts +0 -20
  162. package/src/tools/omada/setClientRateLimit.ts +0 -26
  163. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  164. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  165. package/src/tools/omada/setSsidEnabled.ts +0 -38
  166. package/src/tools/omada/unblockClient.ts +0 -26
  167. package/src/tools/types.ts +0 -11
  168. package/src/tools/utils/pagination-schema.ts +0 -27
  169. package/src/types/ai/index.ts +0 -11
  170. package/src/types/ai/types.ts +0 -67
  171. package/src/types/homeassistant/automations.ts +0 -43
  172. package/src/types/homeassistant/calendars.ts +0 -26
  173. package/src/types/homeassistant/config.ts +0 -14
  174. package/src/types/homeassistant/entities.ts +0 -14
  175. package/src/types/homeassistant/errors.ts +0 -33
  176. package/src/types/homeassistant/haConfig.ts +0 -7
  177. package/src/types/homeassistant/history.ts +0 -13
  178. package/src/types/homeassistant/index.ts +0 -46
  179. package/src/types/homeassistant/request.ts +0 -6
  180. package/src/types/homeassistant/serviceCall.ts +0 -18
  181. package/src/types/homeassistant/states.ts +0 -58
  182. package/src/types/homeassistant/updates.ts +0 -28
  183. package/src/types/index.ts +0 -12
  184. package/src/types/omada/activeClientInfo.ts +0 -17
  185. package/src/types/omada/clientActivity.ts +0 -32
  186. package/src/types/omada/clientBlockStatus.ts +0 -11
  187. package/src/types/omada/clientPastConnection.ts +0 -82
  188. package/src/types/omada/index.ts +0 -14
  189. package/src/types/omada/omadaApiResponse.ts +0 -5
  190. package/src/types/omada/omadaClientInfo.ts +0 -11
  191. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  192. package/src/types/omada/omadaDeviceStats.ts +0 -31
  193. package/src/types/omada/omadaSiteSummary.ts +0 -5
  194. package/src/types/omada/oswStackDetail.ts +0 -11
  195. package/src/types/omada/paginatedResult.ts +0 -6
  196. package/src/types/omada/rateLimitProfile.ts +0 -58
  197. package/src/types/omada/threatInfo.ts +0 -38
  198. package/src/types/omada/tokenResult.ts +0 -6
  199. package/src/types.ts +0 -2
  200. package/src/utils/config-validations.ts +0 -68
  201. package/src/utils/jwt.ts +0 -112
  202. package/src/utils/logger.ts +0 -121
  203. package/src/utils/plainText.ts +0 -48
  204. package/src/utils/redact.ts +0 -59
  205. package/src/utils/sanitizeError.ts +0 -18
  206. package/src/utils/tlsDispatcher.ts +0 -69
  207. package/src/version.ts +0 -5
  208. package/tests/bdd/automation-creation.spec.ts +0 -214
  209. package/tests/bdd/automation-management.spec.ts +0 -267
  210. package/tests/bdd/calendar.spec.ts +0 -190
  211. package/tests/bdd/configuration.spec.ts +0 -283
  212. package/tests/bdd/device-control.spec.ts +0 -344
  213. package/tests/bdd/entity-listing.spec.ts +0 -208
  214. package/tests/bdd/entity-search.spec.ts +0 -183
  215. package/tests/bdd/history-retrieval.spec.ts +0 -195
  216. package/tests/bdd/notifications.spec.ts +0 -238
  217. package/tests/bdd/service-calls.spec.ts +0 -144
  218. package/tests/bdd/state-retrieval.spec.ts +0 -204
  219. package/tests/bench/hotpaths.bench.ts +0 -68
  220. package/tests/config.test.ts +0 -355
  221. package/tests/features/automation-creation.feature +0 -36
  222. package/tests/features/automation-management.feature +0 -54
  223. package/tests/features/calendar.feature +0 -37
  224. package/tests/features/configuration.feature +0 -70
  225. package/tests/features/device-control.feature +0 -100
  226. package/tests/features/entity-listing.feature +0 -51
  227. package/tests/features/entity-search.feature +0 -44
  228. package/tests/features/history-retrieval.feature +0 -38
  229. package/tests/features/notifications.feature +0 -37
  230. package/tests/features/service-calls.feature +0 -35
  231. package/tests/features/state-retrieval.feature +0 -37
  232. package/tests/fixtures/openapi-default-slice.json +0 -290
  233. package/tests/haClient/automationTrace.test.ts +0 -59
  234. package/tests/haClient/automations.test.ts +0 -149
  235. package/tests/haClient/config.test.ts +0 -117
  236. package/tests/haClient/entities.test.ts +0 -188
  237. package/tests/haClient/events.test.ts +0 -455
  238. package/tests/haClient/eventsTls.test.ts +0 -52
  239. package/tests/haClient/history.test.ts +0 -228
  240. package/tests/haClient/index.test.ts +0 -243
  241. package/tests/haClient/request.test.ts +0 -217
  242. package/tests/haClient/services.test.ts +0 -120
  243. package/tests/haClient/states.test.ts +0 -119
  244. package/tests/haClient/updates.test.ts +0 -193
  245. package/tests/index.test.ts +0 -160
  246. package/tests/integration/homeassistant.test.ts +0 -144
  247. package/tests/integration/omada.test.ts +0 -167
  248. package/tests/integration/server.test.ts +0 -151
  249. package/tests/localAI/index.test.ts +0 -177
  250. package/tests/localAI/ollama.test.ts +0 -187
  251. package/tests/localAI/openai.test.ts +0 -257
  252. package/tests/omadaClient/actions.test.ts +0 -161
  253. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  254. package/tests/omadaClient/ntp.test.ts +0 -381
  255. package/tests/omadaClient/site.test.ts +0 -37
  256. package/tests/ownerQuestions/service.test.ts +0 -499
  257. package/tests/ownerQuestions/tools.test.ts +0 -186
  258. package/tests/permissions/index.test.ts +0 -217
  259. package/tests/resources/index.test.ts +0 -288
  260. package/tests/scripts/generateJwt.test.ts +0 -44
  261. package/tests/security/accessControl.test.ts +0 -223
  262. package/tests/security/authentication.test.ts +0 -117
  263. package/tests/security/configuration.test.ts +0 -170
  264. package/tests/security/cryptoTransport.test.ts +0 -172
  265. package/tests/security/dependencies.test.ts +0 -152
  266. package/tests/security/injection.test.ts +0 -117
  267. package/tests/security/insecureDesign.test.ts +0 -121
  268. package/tests/security/logging.test.ts +0 -131
  269. package/tests/security/resourceConsumption.test.ts +0 -142
  270. package/tests/server/auth.test.ts +0 -249
  271. package/tests/server/chatFace.test.ts +0 -320
  272. package/tests/server/common.test.ts +0 -84
  273. package/tests/server/http.test.ts +0 -561
  274. package/tests/server/instructions.test.ts +0 -152
  275. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  276. package/tests/server/stdio.test.ts +0 -99
  277. package/tests/server/stream.test.ts +0 -285
  278. package/tests/server/toolVisibility.http.test.ts +0 -111
  279. package/tests/server/toolVisibility.test.ts +0 -129
  280. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  281. package/tests/tools/common.test.ts +0 -123
  282. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  283. package/tests/tools/infra/invokeAction.test.ts +0 -280
  284. package/tests/tools/omada/graph.test.ts +0 -385
  285. package/tests/tools/omada/handlers.test.ts +0 -897
  286. package/tests/tools/omada/namespace.test.ts +0 -483
  287. package/tests/tools/registry.test.ts +0 -168
  288. package/tests/utils/config-validations.test.ts +0 -352
  289. package/tests/utils/jwt.test.ts +0 -115
  290. package/tests/utils/logger.test.ts +0 -327
  291. package/tests/utils/redact.test.ts +0 -105
  292. package/tsconfig.json +0 -24
  293. 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
- }