@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,789 +0,0 @@
1
- /**
2
- * Chat face — the OpenAPI/REST surface consumed by Open WebUI (Harvey's voice).
3
- *
4
- * THE FACES PROBLEM (v1.7): this server answers on two doors — the MCP face
5
- * (`/mcp`, ~60 tools) and this REST face (`/openapi.json` + `/api/*`). Before
6
- * v1.7 the REST face was THREE hand-maintained implementations (route handlers,
7
- * a permission map, and a hand-written spec object) that had to be kept in sync
8
- * by hand — so it fossilized at the 8 HA tools wired in the v1.5.1 era.
9
- *
10
- * Now ONE binding registry drives all three:
11
- * - route dispatch (`dispatchChatRequest`)
12
- * - per-route RBAC (`binding.permission`, same Permission bits as MCP)
13
- * - the generated spec (`buildOpenApiSpec`)
14
- *
15
- * Two locks decide what chat can see:
16
- * 1. Code: a tool must have a binding here at all (chat-ELIGIBLE).
17
- * 2. Operator: `MCP_CHAT_TOOLS=ha-core:rw,omada-read:r` selects which
18
- * category:access slices are ACTIVE (unset = exactly the legacy 8 —
19
- * zero-surprise upgrade).
20
- *
21
- * SECURITY INVARIANT (tested): ADMIN-bit tools (invokeAction) can never be
22
- * chat-faced. Chat is the most prompt-injectable surface; the deploy trigger
23
- * stays off it structurally, not by prompt-politeness.
24
- */
25
-
26
- import type { IncomingMessage, ServerResponse } from 'node:http';
27
- import { z } from 'zod';
28
-
29
- import type { ChatCategory, ChatAccess, ChatToolsSlice } from './chatSlice.js';
30
-
31
- import type { HaClient } from '../haClient/index.js';
32
- import type { OmadaClient } from '../omadaClient/index.js';
33
- import { Permission, hasPermission, getPermissionNames } from '../permissions/index.js';
34
- import type { AuthenticatedRequest } from './auth.js';
35
- import { logger } from '../utils/logger.js';
36
- import { sanitizeError } from '../utils/sanitizeError.js';
37
- import { VERSION } from '../version.js';
38
- import type { ToolExtra } from '../tools/common.js';
39
- import type { CallToolResult } from '@modelcontextprotocol/server';
40
- import { createBrowseHandler, createReadHandler, browseSchema, readSchema } from '../tools/omada/graph.js';
41
- import { createSetSsidEnabledHandler, setSsidEnabledSchema } from '../tools/omada/setSsidEnabled.js';
42
- import { createGetDomainSummaryHandler } from '../tools/homeassistant/getDomainSummary.js';
43
- import { createListAutomationsHandler } from '../tools/homeassistant/listAutomations.js';
44
- import { domainSchema, automationFilterSchema } from '../tools/common.js';
45
-
46
- // ---------------------------------------------------------------------------
47
- // Categories + operator slice (MCP_CHAT_TOOLS) — parsing lives in chatSlice.ts
48
- // (zero-import module) so config.ts can use it without import cycles.
49
- // ---------------------------------------------------------------------------
50
-
51
- export { CHAT_CATEGORIES, parseChatTools } from './chatSlice.js';
52
- export type { ChatCategory, ChatAccess, ChatToolsSlice } from './chatSlice.js';
53
-
54
- // ---------------------------------------------------------------------------
55
- // Binding model
56
- // ---------------------------------------------------------------------------
57
-
58
- /** Clients a binding's handler may need. */
59
- export interface ChatDeps {
60
- haClient?: HaClient;
61
- omadaClient?: OmadaClient;
62
- }
63
-
64
- interface ChatRequestContext {
65
- /** Path parameters captured from the URL template. */
66
- params: Record<string, string>;
67
- /** Query string parameters. */
68
- query: URLSearchParams;
69
- /** Parsed JSON body (POST only; undefined otherwise). */
70
- body: unknown;
71
- /** Caller's permission mask (from the auth middleware). */
72
- permissions: number;
73
- deps: ChatDeps;
74
- }
75
-
76
- interface ChatResponse {
77
- status: number;
78
- body: unknown;
79
- }
80
-
81
- export interface ChatBinding {
82
- /** OpenAPI operationId; also the tool's identity for invariants/tests. */
83
- operationId: string;
84
- category: ChatCategory;
85
- access: ChatAccess;
86
- /** RBAC bit required on the route — mirrors the MCP-tool twin (BFLA guard). */
87
- permission: number;
88
- method: 'GET' | 'POST';
89
- /** Path template, e.g. `/api/states/{entity_id}`. */
90
- pathTemplate: string;
91
- /** Which client the handler needs (503 when absent). */
92
- requires: 'ha' | 'omada';
93
- /** OpenAPI operation object (verbatim for legacy; generated for tool bindings). */
94
- spec: Record<string, unknown>;
95
- handle: (ctx: ChatRequestContext) => Promise<ChatResponse>;
96
- }
97
-
98
- /** Compile a `/api/x/{a}/{b}` template into a matcher returning named params. */
99
- function compileMatcher(template: string): (pathname: string) => Record<string, string> | null {
100
- const names: string[] = [];
101
- const pattern = template.replace(/\{([^}]+)\}/g, (_m, name: string) => {
102
- names.push(name);
103
- return '([^/]+)';
104
- });
105
- const regex = new RegExp(`^${pattern}$`);
106
- return (pathname: string) => {
107
- const m = pathname.match(regex);
108
- if (!m) return null;
109
- const params: Record<string, string> = {};
110
- names.forEach((name, i) => {
111
- params[name] = decodeURIComponent(m[i + 1]);
112
- });
113
- return params;
114
- };
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Legacy bindings — the pre-v1.7 REST surface, verbatim.
119
- //
120
- // Handlers intentionally keep their original response CONTRACTS (raw HA-style
121
- // JSON, not the MCP tools' paginated envelopes) — Open WebUI has consumed these
122
- // shapes since v1.5.x, and Phase 1 is byte-compatible by design. The golden-file
123
- // test (tests/fixtures/openapi-default-slice.json) pins the spec.
124
- // ---------------------------------------------------------------------------
125
-
126
- const entityStateArraySchema = {
127
- type: 'array',
128
- items: { $ref: '#/components/schemas/EntityState' },
129
- };
130
-
131
- function ha(ctx: ChatRequestContext): HaClient {
132
- // `requires: 'ha'` guarantees presence; the dispatcher 503s before this runs.
133
- return ctx.deps.haClient as HaClient;
134
- }
135
-
136
- export const LEGACY_BINDINGS: ChatBinding[] = [
137
- {
138
- operationId: 'getStates',
139
- category: 'ha-core',
140
- access: 'read',
141
- permission: Permission.QUERY,
142
- method: 'GET',
143
- pathTemplate: '/api/states',
144
- requires: 'ha',
145
- spec: {
146
- operationId: 'getStates',
147
- summary: 'Get all entity states from Home Assistant',
148
- responses: {
149
- '200': {
150
- description: 'List of all entity states',
151
- content: { 'application/json': { schema: entityStateArraySchema } },
152
- },
153
- },
154
- },
155
- handle: async (ctx) => ({ status: 200, body: await ha(ctx).getStates() }),
156
- },
157
- {
158
- operationId: 'getState',
159
- category: 'ha-core',
160
- access: 'read',
161
- permission: Permission.QUERY,
162
- method: 'GET',
163
- pathTemplate: '/api/states/{entity_id}',
164
- requires: 'ha',
165
- spec: {
166
- operationId: 'getState',
167
- summary: 'Get state of a specific entity',
168
- parameters: [
169
- { name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID (e.g., light.living_room)' },
170
- ],
171
- responses: {
172
- '200': {
173
- description: 'Entity state',
174
- content: { 'application/json': { schema: { $ref: '#/components/schemas/EntityState' } } },
175
- },
176
- },
177
- },
178
- handle: async (ctx) => {
179
- const entityId = ctx.params.entity_id;
180
- const state = await ha(ctx).getState(entityId);
181
- return state
182
- ? { status: 200, body: state }
183
- : { status: 404, body: { error: `Entity ${entityId} not found` } };
184
- },
185
- },
186
- {
187
- operationId: 'callService',
188
- category: 'ha-core',
189
- access: 'write',
190
- permission: Permission.CONTROL,
191
- method: 'POST',
192
- pathTemplate: '/api/services/{domain}/{service}',
193
- requires: 'ha',
194
- spec: {
195
- operationId: 'callService',
196
- summary: 'Call a Home Assistant service',
197
- parameters: [
198
- { name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Service domain (e.g., light, switch)' },
199
- { name: 'service', in: 'path', required: true, schema: { type: 'string' }, description: 'Service name (e.g., turn_on, turn_off)' },
200
- ],
201
- requestBody: {
202
- content: {
203
- 'application/json': {
204
- schema: {
205
- type: 'object',
206
- properties: {
207
- entity_id: { type: 'string', description: 'Target entity ID' },
208
- data: { type: 'object', description: 'Service data' },
209
- },
210
- },
211
- },
212
- },
213
- },
214
- responses: { '200': { description: 'Service call result' } },
215
- },
216
- handle: async (ctx) => {
217
- const body = ctx.body as { entity_id?: string; data?: Record<string, unknown> } | undefined;
218
- const result = await ha(ctx).callService({
219
- domain: ctx.params.domain,
220
- service: ctx.params.service,
221
- target: body?.entity_id ? { entity_id: body.entity_id } : undefined,
222
- service_data: body?.data,
223
- });
224
- return { status: 200, body: result };
225
- },
226
- },
227
- {
228
- operationId: 'getAllSensors',
229
- category: 'ha-core',
230
- access: 'read',
231
- permission: Permission.QUERY,
232
- method: 'GET',
233
- pathTemplate: '/api/sensors',
234
- requires: 'ha',
235
- spec: {
236
- operationId: 'getAllSensors',
237
- summary: 'Get all sensor and binary_sensor states',
238
- responses: {
239
- '200': {
240
- description: 'List of sensor states',
241
- content: { 'application/json': { schema: entityStateArraySchema } },
242
- },
243
- },
244
- },
245
- handle: async (ctx) => {
246
- const states = await ha(ctx).getStates();
247
- const sensors = states.filter(
248
- (s: { entity_id: string }) =>
249
- s.entity_id.startsWith('sensor.') || s.entity_id.startsWith('binary_sensor.')
250
- );
251
- return { status: 200, body: sensors };
252
- },
253
- },
254
- {
255
- operationId: 'getEntitiesByDomain',
256
- category: 'ha-core',
257
- access: 'read',
258
- permission: Permission.QUERY,
259
- method: 'GET',
260
- pathTemplate: '/api/entities/{domain}',
261
- requires: 'ha',
262
- spec: {
263
- operationId: 'getEntitiesByDomain',
264
- summary: 'Get all entities for a specific domain',
265
- parameters: [
266
- { name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Domain name (e.g., light, sensor)' },
267
- ],
268
- responses: {
269
- '200': {
270
- description: 'List of entities in domain',
271
- content: { 'application/json': { schema: entityStateArraySchema } },
272
- },
273
- },
274
- },
275
- handle: async (ctx) => {
276
- const states = await ha(ctx).getStates();
277
- const filtered = states.filter((s: { entity_id: string }) =>
278
- s.entity_id.startsWith(`${ctx.params.domain}.`)
279
- );
280
- return { status: 200, body: filtered };
281
- },
282
- },
283
- {
284
- operationId: 'searchEntities',
285
- category: 'ha-core',
286
- access: 'read',
287
- permission: Permission.QUERY,
288
- method: 'GET',
289
- pathTemplate: '/api/search',
290
- requires: 'ha',
291
- spec: {
292
- operationId: 'searchEntities',
293
- summary: 'Search entities by name or ID',
294
- parameters: [
295
- { name: 'q', in: 'query', required: true, schema: { type: 'string' }, description: 'Search query' },
296
- ],
297
- responses: {
298
- '200': {
299
- description: 'Matching entities',
300
- content: { 'application/json': { schema: entityStateArraySchema } },
301
- },
302
- },
303
- },
304
- handle: async (ctx) => {
305
- const query = ctx.query.get('q')?.toLowerCase() ?? '';
306
- const states = await ha(ctx).getStates();
307
- const filtered = states.filter(
308
- (s: { entity_id: string; attributes?: { friendly_name?: string } }) =>
309
- s.entity_id.toLowerCase().includes(query) ||
310
- s.attributes?.friendly_name?.toLowerCase().includes(query)
311
- );
312
- return { status: 200, body: filtered };
313
- },
314
- },
315
- {
316
- operationId: 'getHistory',
317
- category: 'ha-history',
318
- access: 'read',
319
- permission: Permission.QUERY,
320
- method: 'GET',
321
- pathTemplate: '/api/history/{entity_id}',
322
- requires: 'ha',
323
- spec: {
324
- operationId: 'getHistory',
325
- summary: 'Get historical data for an entity',
326
- parameters: [
327
- { name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID' },
328
- { name: 'hours', in: 'query', schema: { type: 'number', default: 24 }, description: 'Hours of history' },
329
- ],
330
- responses: {
331
- '200': {
332
- description: 'Historical data',
333
- content: { 'application/json': { schema: { type: 'array' } } },
334
- },
335
- },
336
- },
337
- handle: async (ctx) => {
338
- const hours = parseInt(ctx.query.get('hours') ?? '24', 10);
339
- const history = await ha(ctx).getHistory(ctx.params.entity_id, hours);
340
- return { status: 200, body: history };
341
- },
342
- },
343
- {
344
- operationId: 'getVersion',
345
- category: 'ha-core',
346
- access: 'read',
347
- permission: Permission.QUERY,
348
- method: 'GET',
349
- pathTemplate: '/api/version',
350
- requires: 'ha',
351
- spec: {
352
- operationId: 'getVersion',
353
- summary: 'Get Home Assistant version information',
354
- responses: {
355
- '200': {
356
- description: 'Version information',
357
- content: {
358
- 'application/json': {
359
- schema: {
360
- type: 'object',
361
- properties: {
362
- ha_version: { type: 'string', description: 'Home Assistant version' },
363
- mcp_version: { type: 'string', description: 'MCP server version' },
364
- },
365
- },
366
- },
367
- },
368
- },
369
- },
370
- },
371
- handle: async (ctx) => {
372
- const haConfig = await ha(ctx).getVersion();
373
- return { status: 200, body: { ha_version: haConfig.version, mcp_version: VERSION } };
374
- },
375
- },
376
- ];
377
-
378
- // The spec paths must render in the ORIGINAL order for byte-compatibility with
379
- // the pre-v1.7 hand-written spec (JSON key order = insertion order). The legacy
380
- // spec listed callService third; LEGACY_BINDINGS above preserves that order.
381
-
382
- // ---------------------------------------------------------------------------
383
- // Zod -> OpenAPI schema (minimal, for tool bindings only)
384
- // ---------------------------------------------------------------------------
385
-
386
- /**
387
- * Convert the small zod subset our chat-faced tools use into an OpenAPI schema.
388
- * Deliberately minimal (no dependency on zod-to-json-schema — adding a dep means
389
- * regenerating the lockfile, a recurring cross-OS trap). Unknown types degrade
390
- * to `{}` (permissive) rather than throwing.
391
- */
392
- export function zodToOpenApiSchema(schema: z.ZodType): Record<string, unknown> {
393
- // zod 4 exposes the schema kind on `_zod.def.type` (zod 3 used `_def.typeName`).
394
- const def = (schema as unknown as { _zod: { def: { type: string } } })._zod.def;
395
-
396
- switch (def.type) {
397
- case 'optional':
398
- case 'default': {
399
- const inner = zodToOpenApiSchema((def as unknown as { innerType: z.ZodType }).innerType);
400
- if (def.type === 'default') {
401
- // zod 4 stores the default as a value (zod 3 stored a getter function).
402
- const dv = (def as unknown as { defaultValue: unknown }).defaultValue;
403
- return { ...inner, default: dv };
404
- }
405
- return inner;
406
- }
407
- case 'string':
408
- return { type: 'string', ...describeOf(schema) };
409
- case 'number':
410
- return { type: 'number', ...describeOf(schema) };
411
- case 'boolean':
412
- return { type: 'boolean', ...describeOf(schema) };
413
- case 'enum':
414
- return {
415
- type: 'string',
416
- enum: (schema as unknown as { options: string[] }).options,
417
- ...describeOf(schema),
418
- };
419
- case 'array':
420
- return {
421
- type: 'array',
422
- items: zodToOpenApiSchema((def as unknown as { element: z.ZodType }).element),
423
- ...describeOf(schema),
424
- };
425
- case 'record':
426
- return { type: 'object', ...describeOf(schema) };
427
- case 'object': {
428
- const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
429
- const properties: Record<string, unknown> = {};
430
- const required: string[] = [];
431
- for (const [key, value] of Object.entries(shape)) {
432
- properties[key] = zodToOpenApiSchema(value as z.ZodType);
433
- if (!(value instanceof z.ZodOptional) && !(value instanceof z.ZodDefault)) {
434
- required.push(key);
435
- }
436
- }
437
- return {
438
- type: 'object',
439
- properties,
440
- ...(required.length > 0 ? { required } : {}),
441
- ...describeOf(schema),
442
- };
443
- }
444
- default:
445
- return {};
446
- }
447
- }
448
-
449
- function describeOf(schema: z.ZodType): { description?: string } {
450
- const description = (schema as { description?: string }).description;
451
- return description ? { description } : {};
452
- }
453
-
454
- // ---------------------------------------------------------------------------
455
- // MCP-tool bindings — new chat tools dispatch THROUGH the shared MCP handler
456
- // (single source of truth: same validation, RBAC, hints, and logging as /mcp).
457
- // ---------------------------------------------------------------------------
458
-
459
- /** Minimal synthetic ToolExtra for REST-originated tool calls. */
460
- function restToolExtra(permissions: number): ToolExtra {
461
- // SDK v2 context shape: auth lives under `http.authInfo` (v1 had it top-level).
462
- // getCallerPermissions reads exactly this path, so a wrong shape here would make
463
- // every REST call fail closed (denied).
464
- return {
465
- sessionId: 'chat-rest',
466
- http: { authInfo: { token: '', clientId: 'chat-rest', scopes: [], extra: { permissions } } },
467
- } as unknown as ToolExtra;
468
- }
469
-
470
- /** Map a CallToolResult back onto an HTTP response. */
471
- function toolResultToResponse(result: CallToolResult): ChatResponse {
472
- const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
473
- let body: unknown = text;
474
- try {
475
- body = JSON.parse(text);
476
- } catch {
477
- /* non-JSON tool output stays a plain string */
478
- }
479
-
480
- if (result.isError) {
481
- const errName = (body as { error?: string } | undefined)?.error;
482
- const status = errName === 'Permission denied' ? 403 : 500;
483
- return { status, body };
484
- }
485
- return { status: 200, body };
486
- }
487
-
488
- interface McpToolBindingOptions {
489
- name: string;
490
- category: ChatCategory;
491
- access: ChatAccess;
492
- permission: number;
493
- requires: 'ha' | 'omada';
494
- summary: string;
495
- inputSchema: z.ZodObject<z.ZodRawShape>;
496
- createHandler: (deps: ChatDeps) => (args: never, extra: ToolExtra) => Promise<CallToolResult>;
497
- }
498
-
499
- /**
500
- * Build a chat binding that exposes an MCP tool as `POST /api/tools/{name}`.
501
- * The request body is the tool's arguments (validated by the tool's own zod
502
- * schema); the response is the tool result's JSON payload.
503
- */
504
- function mcpToolBinding(options: McpToolBindingOptions): ChatBinding {
505
- const { name, category, access, permission, requires, summary, inputSchema, createHandler } = options;
506
- return {
507
- operationId: name,
508
- category,
509
- access,
510
- permission,
511
- method: 'POST',
512
- pathTemplate: `/api/tools/${name}`,
513
- requires,
514
- spec: {
515
- operationId: name,
516
- summary,
517
- requestBody: {
518
- content: { 'application/json': { schema: zodToOpenApiSchema(inputSchema) } },
519
- },
520
- responses: { '200': { description: 'Tool result' } },
521
- },
522
- handle: async (ctx) => {
523
- const parsed = inputSchema.safeParse(ctx.body ?? {});
524
- if (!parsed.success) {
525
- return {
526
- status: 400,
527
- body: {
528
- error: 'Invalid arguments',
529
- issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
530
- },
531
- };
532
- }
533
- const handler = createHandler(ctx.deps);
534
- const result = await handler(parsed.data as never, restToolExtra(ctx.permissions));
535
- return toolResultToResponse(result);
536
- },
537
- };
538
- }
539
-
540
- /**
541
- * Chat-ELIGIBLE MCP tools. Adding an entry makes a tool *available* to the chat
542
- * face; the operator's MCP_CHAT_TOOLS slice decides whether it's *active*.
543
- *
544
- * NEVER list ADMIN-bit tools here (invokeAction). The invariant test enforces it.
545
- * omada_cyclePoePort is deliberately absent for now — hard power action.
546
- */
547
- export const CHAT_TOOL_BINDINGS: ChatBinding[] = [
548
- mcpToolBinding({
549
- name: 'getDomainSummary',
550
- category: 'ha-core',
551
- access: 'read',
552
- permission: Permission.QUERY,
553
- requires: 'ha',
554
- summary: 'Get a summary of entities in a domain (counts and state breakdown)',
555
- inputSchema: domainSchema,
556
- createHandler: (deps) => createGetDomainSummaryHandler(deps.haClient as HaClient),
557
- }),
558
- mcpToolBinding({
559
- name: 'listAutomations',
560
- category: 'ha-automations',
561
- access: 'read',
562
- permission: Permission.QUERY,
563
- requires: 'ha',
564
- summary: 'List Home Assistant automations with status and last-triggered time',
565
- inputSchema: automationFilterSchema,
566
- createHandler: (deps) => createListAutomationsHandler(deps.haClient as HaClient),
567
- }),
568
- mcpToolBinding({
569
- name: 'omada_browse',
570
- category: 'omada-read',
571
- access: 'read',
572
- permission: Permission.QUERY,
573
- requires: 'omada',
574
- summary: 'Discover the Omada network resource graph (browse from "/" downward)',
575
- inputSchema: browseSchema,
576
- createHandler: (deps) => createBrowseHandler(deps.omadaClient as OmadaClient),
577
- }),
578
- mcpToolBinding({
579
- name: 'omada_read',
580
- category: 'omada-read',
581
- access: 'read',
582
- // Route-level QUERY; each resource path enforces its own bit inside the handler.
583
- permission: Permission.QUERY,
584
- requires: 'omada',
585
- summary: 'Read data from an Omada resource path discovered via omada_browse',
586
- inputSchema: readSchema,
587
- createHandler: (deps) => createReadHandler(deps.omadaClient as OmadaClient),
588
- }),
589
- mcpToolBinding({
590
- name: 'omada_setSsidEnabled',
591
- category: 'omada-write',
592
- access: 'write',
593
- permission: Permission.CONTROL,
594
- requires: 'omada',
595
- summary: 'Turn an SSID (e.g. the guest WiFi) on or off by name — reversible',
596
- inputSchema: setSsidEnabledSchema,
597
- createHandler: (deps) => createSetSsidEnabledHandler(deps.omadaClient as OmadaClient),
598
- }),
599
- ];
600
-
601
- // ---------------------------------------------------------------------------
602
- // Slice resolution + spec generation + dispatch
603
- // ---------------------------------------------------------------------------
604
-
605
- /**
606
- * Resolve the ACTIVE chat bindings.
607
- *
608
- * - Slice unset (MCP_CHAT_TOOLS not configured) -> exactly the legacy 8
609
- * (pre-v1.7 behavior, byte-compatible spec).
610
- * - Slice set -> it governs BOTH legacy and tool bindings by category:access.
611
- * The slice can only narrow relative to what the code marks chat-eligible.
612
- */
613
- export function resolveChatBindings(slice: ChatToolsSlice | undefined, deps: ChatDeps): ChatBinding[] {
614
- const all = slice === undefined ? LEGACY_BINDINGS : [...LEGACY_BINDINGS, ...CHAT_TOOL_BINDINGS];
615
-
616
- return all.filter((binding) => {
617
- if (slice !== undefined) {
618
- const levels = slice.get(binding.category);
619
- if (!levels || !levels.has(binding.access)) {
620
- return false;
621
- }
622
- }
623
- // A binding whose client isn't configured is dropped from the face entirely
624
- // (absent from the spec, 404 on the route) rather than serving 503s.
625
- if (binding.requires === 'omada' && !deps.omadaClient) return false;
626
- // HA bindings stay visible without a client for legacy parity (503 at call
627
- // time via dispatch, matching pre-v1.7 behavior).
628
- return true;
629
- });
630
- }
631
-
632
- /** Generate the OpenAPI spec for the active chat face. */
633
- export function buildOpenApiSpec(baseUrl: string, bindings: ChatBinding[]): object {
634
- const paths: Record<string, Record<string, unknown>> = {};
635
- for (const binding of bindings) {
636
- const item = paths[binding.pathTemplate] ?? {};
637
- item[binding.method.toLowerCase()] = binding.spec;
638
- paths[binding.pathTemplate] = item;
639
- }
640
-
641
- return {
642
- openapi: '3.1.0',
643
- info: {
644
- title: 'Home Assistant MCP Tools',
645
- description: 'MCP server for Home Assistant - exposes entity states and service calls',
646
- version: VERSION,
647
- },
648
- servers: [{ url: baseUrl }],
649
- paths,
650
- components: {
651
- schemas: {
652
- EntityState: {
653
- type: 'object',
654
- properties: {
655
- entity_id: { type: 'string' },
656
- state: { type: 'string' },
657
- attributes: { type: 'object' },
658
- last_changed: { type: 'string' },
659
- last_updated: { type: 'string' },
660
- },
661
- },
662
- },
663
- },
664
- };
665
- }
666
-
667
- /** Compiled matcher cache (module-level; templates are static). */
668
- const matcherCache = new Map<string, (pathname: string) => Record<string, string> | null>();
669
-
670
- function matcherFor(template: string): (pathname: string) => Record<string, string> | null {
671
- let matcher = matcherCache.get(template);
672
- if (!matcher) {
673
- matcher = compileMatcher(template);
674
- matcherCache.set(template, matcher);
675
- }
676
- return matcher;
677
- }
678
-
679
- /**
680
- * Resolve the permission a REST route requires under the given bindings.
681
- * Exposed for tests and for auth-layer reasoning; returns null for unknown
682
- * routes (they 404 without needing a permission).
683
- */
684
- export function requiredPermissionFor(
685
- bindings: ChatBinding[],
686
- method: string,
687
- pathname: string
688
- ): number | null {
689
- const m = method.toUpperCase();
690
- for (const binding of bindings) {
691
- if (binding.method !== m) continue;
692
- if (matcherFor(binding.pathTemplate)(pathname)) {
693
- return binding.permission;
694
- }
695
- }
696
- return null;
697
- }
698
-
699
- export interface DispatchOptions {
700
- bindings: ChatBinding[];
701
- deps: ChatDeps;
702
- /** Parse the request body (injected so http.ts's size-capped parser is used). */
703
- parseBody: (req: IncomingMessage) => Promise<unknown>;
704
- sendJson: (res: ServerResponse, status: number, body: unknown) => void;
705
- }
706
-
707
- /**
708
- * Dispatch a `/api/*` request against the active bindings: match, enforce the
709
- * route's RBAC bit (fail closed), parse the body for POSTs, run the handler.
710
- * Unknown routes 404; missing clients 503 — same semantics as the pre-v1.7
711
- * hand-written bridge.
712
- */
713
- export async function dispatchChatRequest(
714
- req: IncomingMessage,
715
- res: ServerResponse,
716
- url: string,
717
- options: DispatchOptions
718
- ): Promise<void> {
719
- const { bindings, deps, parseBody, sendJson } = options;
720
- const parsedUrl = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
721
- const pathname = parsedUrl.pathname;
722
- const method = (req.method ?? 'GET').toUpperCase();
723
-
724
- // Find the matching binding.
725
- let matched: { binding: ChatBinding; params: Record<string, string> } | undefined;
726
- for (const binding of bindings) {
727
- if (binding.method !== method) continue;
728
- const params = matcherFor(binding.pathTemplate)(pathname);
729
- if (params) {
730
- matched = { binding, params };
731
- break;
732
- }
733
- }
734
-
735
- if (!matched) {
736
- sendJson(res, 404, { error: 'API endpoint not found' });
737
- return;
738
- }
739
- const { binding, params } = matched;
740
-
741
- // Fail closed: a missing mask grants nothing (H1 / BFLA guard).
742
- const permissions = ((req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined) ?? 0;
743
- if (!hasPermission(permissions, binding.permission)) {
744
- logger.warn('REST API permission denied', {
745
- method,
746
- path: pathname,
747
- required: getPermissionNames(binding.permission),
748
- has: getPermissionNames(permissions),
749
- });
750
- sendJson(res, 403, {
751
- error: 'Forbidden',
752
- message: `This endpoint requires permission: ${getPermissionNames(binding.permission).join(', ')}`,
753
- });
754
- return;
755
- }
756
-
757
- if (binding.requires === 'ha' && !deps.haClient) {
758
- sendJson(res, 503, { error: 'Home Assistant not configured' });
759
- return;
760
- }
761
- if (binding.requires === 'omada' && !deps.omadaClient) {
762
- sendJson(res, 503, { error: 'Omada not configured' });
763
- return;
764
- }
765
-
766
- try {
767
- const body = method === 'POST' ? await parseBody(req) : undefined;
768
- const response = await binding.handle({
769
- params,
770
- query: parsedUrl.searchParams,
771
- body,
772
- permissions,
773
- deps,
774
- });
775
- sendJson(res, response.status, response.body);
776
- } catch (error) {
777
- // M5: a body that exceeded the size cap surfaces as 413, not a generic 500.
778
- const statusCode = (error as { statusCode?: number } | undefined)?.statusCode;
779
- if (statusCode === 413) {
780
- if (!res.headersSent) {
781
- sendJson(res, 413, { error: 'Payload Too Large', message: 'Request body exceeds the size limit' });
782
- }
783
- return;
784
- }
785
- logger.error('REST API error', { error, url });
786
- // M6: log the detail above; never echo raw error text to the client.
787
- sendJson(res, 500, { error: 'Internal server error', message: sanitizeError(error) });
788
- }
789
- }