@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,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
- }