@coffeerunhobby/mcp-ha-connect 0.5.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 (294) hide show
  1. package/.dockerignore +8 -0
  2. package/.env.example +149 -0
  3. package/.gitattributes +30 -0
  4. package/Dockerfile +33 -0
  5. package/LICENSE +21 -0
  6. package/README.md +394 -0
  7. package/dist/config.d.ts +33 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +183 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/haClient/automations.d.ts +67 -0
  12. package/dist/haClient/automations.d.ts.map +1 -0
  13. package/dist/haClient/automations.js +181 -0
  14. package/dist/haClient/automations.js.map +1 -0
  15. package/dist/haClient/config.d.ts +28 -0
  16. package/dist/haClient/config.d.ts.map +1 -0
  17. package/dist/haClient/config.js +38 -0
  18. package/dist/haClient/config.js.map +1 -0
  19. package/dist/haClient/devices.d.ts +219 -0
  20. package/dist/haClient/devices.d.ts.map +1 -0
  21. package/dist/haClient/devices.js +506 -0
  22. package/dist/haClient/devices.js.map +1 -0
  23. package/dist/haClient/entities.d.ts +35 -0
  24. package/dist/haClient/entities.d.ts.map +1 -0
  25. package/dist/haClient/entities.js +97 -0
  26. package/dist/haClient/entities.js.map +1 -0
  27. package/dist/haClient/events.d.ts +138 -0
  28. package/dist/haClient/events.d.ts.map +1 -0
  29. package/dist/haClient/events.js +344 -0
  30. package/dist/haClient/events.js.map +1 -0
  31. package/dist/haClient/history.d.ts +26 -0
  32. package/dist/haClient/history.d.ts.map +1 -0
  33. package/dist/haClient/history.js +54 -0
  34. package/dist/haClient/history.js.map +1 -0
  35. package/dist/haClient/index.d.ts +153 -0
  36. package/dist/haClient/index.d.ts.map +1 -0
  37. package/dist/haClient/index.js +223 -0
  38. package/dist/haClient/index.js.map +1 -0
  39. package/dist/haClient/request.d.ts +34 -0
  40. package/dist/haClient/request.d.ts.map +1 -0
  41. package/dist/haClient/request.js +93 -0
  42. package/dist/haClient/request.js.map +1 -0
  43. package/dist/haClient/services.d.ts +22 -0
  44. package/dist/haClient/services.d.ts.map +1 -0
  45. package/dist/haClient/services.js +39 -0
  46. package/dist/haClient/services.js.map +1 -0
  47. package/dist/haClient/states.d.ts +26 -0
  48. package/dist/haClient/states.d.ts.map +1 -0
  49. package/dist/haClient/states.js +53 -0
  50. package/dist/haClient/states.js.map +1 -0
  51. package/dist/haClient/updates.d.ts +22 -0
  52. package/dist/haClient/updates.d.ts.map +1 -0
  53. package/dist/haClient/updates.js +48 -0
  54. package/dist/haClient/updates.js.map +1 -0
  55. package/dist/haClient.d.ts +97 -0
  56. package/dist/haClient.d.ts.map +1 -0
  57. package/dist/haClient.js +344 -0
  58. package/dist/haClient.js.map +1 -0
  59. package/dist/index.d.ts +8 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +92 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/localAI/index.d.ts +37 -0
  64. package/dist/localAI/index.d.ts.map +1 -0
  65. package/dist/localAI/index.js +73 -0
  66. package/dist/localAI/index.js.map +1 -0
  67. package/dist/localAI/prompts.d.ts +20 -0
  68. package/dist/localAI/prompts.d.ts.map +1 -0
  69. package/dist/localAI/prompts.js +48 -0
  70. package/dist/localAI/prompts.js.map +1 -0
  71. package/dist/localAI/providers/base.d.ts +51 -0
  72. package/dist/localAI/providers/base.d.ts.map +1 -0
  73. package/dist/localAI/providers/base.js +80 -0
  74. package/dist/localAI/providers/base.js.map +1 -0
  75. package/dist/localAI/providers/ollama.d.ts +22 -0
  76. package/dist/localAI/providers/ollama.d.ts.map +1 -0
  77. package/dist/localAI/providers/ollama.js +63 -0
  78. package/dist/localAI/providers/ollama.js.map +1 -0
  79. package/dist/localAI/providers/openai.d.ts +23 -0
  80. package/dist/localAI/providers/openai.d.ts.map +1 -0
  81. package/dist/localAI/providers/openai.js +63 -0
  82. package/dist/localAI/providers/openai.js.map +1 -0
  83. package/dist/localAI/types.d.ts +61 -0
  84. package/dist/localAI/types.d.ts.map +1 -0
  85. package/dist/localAI/types.js +6 -0
  86. package/dist/localAI/types.js.map +1 -0
  87. package/dist/ollamaClient.d.ts +37 -0
  88. package/dist/ollamaClient.d.ts.map +1 -0
  89. package/dist/ollamaClient.js +110 -0
  90. package/dist/ollamaClient.js.map +1 -0
  91. package/dist/resources/index.d.ts +11 -0
  92. package/dist/resources/index.d.ts.map +1 -0
  93. package/dist/resources/index.js +238 -0
  94. package/dist/resources/index.js.map +1 -0
  95. package/dist/server/common.d.ts +9 -0
  96. package/dist/server/common.d.ts.map +1 -0
  97. package/dist/server/common.js +27 -0
  98. package/dist/server/common.js.map +1 -0
  99. package/dist/server/eventSubscription.d.ts +53 -0
  100. package/dist/server/eventSubscription.d.ts.map +1 -0
  101. package/dist/server/eventSubscription.js +257 -0
  102. package/dist/server/eventSubscription.js.map +1 -0
  103. package/dist/server/http.d.ts +12 -0
  104. package/dist/server/http.d.ts.map +1 -0
  105. package/dist/server/http.js +491 -0
  106. package/dist/server/http.js.map +1 -0
  107. package/dist/server/rateLimiter.d.ts +70 -0
  108. package/dist/server/rateLimiter.d.ts.map +1 -0
  109. package/dist/server/rateLimiter.js +156 -0
  110. package/dist/server/rateLimiter.js.map +1 -0
  111. package/dist/server/sse.d.ts +32 -0
  112. package/dist/server/sse.d.ts.map +1 -0
  113. package/dist/server/sse.js +76 -0
  114. package/dist/server/sse.js.map +1 -0
  115. package/dist/server/stdio.d.ts +8 -0
  116. package/dist/server/stdio.d.ts.map +1 -0
  117. package/dist/server/stdio.js +16 -0
  118. package/dist/server/stdio.js.map +1 -0
  119. package/dist/server/stream.d.ts +26 -0
  120. package/dist/server/stream.d.ts.map +1 -0
  121. package/dist/server/stream.js +81 -0
  122. package/dist/server/stream.js.map +1 -0
  123. package/dist/server.d.ts +8 -0
  124. package/dist/server.d.ts.map +1 -0
  125. package/dist/server.js +30 -0
  126. package/dist/server.js.map +1 -0
  127. package/dist/tools/callService.d.ts +8 -0
  128. package/dist/tools/callService.d.ts.map +1 -0
  129. package/dist/tools/callService.js +100 -0
  130. package/dist/tools/callService.js.map +1 -0
  131. package/dist/tools/getClient.d.ts +8 -0
  132. package/dist/tools/getClient.d.ts.map +1 -0
  133. package/dist/tools/getClient.js +94 -0
  134. package/dist/tools/getClient.js.map +1 -0
  135. package/dist/tools/getDevice.d.ts +8 -0
  136. package/dist/tools/getDevice.d.ts.map +1 -0
  137. package/dist/tools/getDevice.js +94 -0
  138. package/dist/tools/getDevice.js.map +1 -0
  139. package/dist/tools/getEntitiesByDomain.d.ts +8 -0
  140. package/dist/tools/getEntitiesByDomain.d.ts.map +1 -0
  141. package/dist/tools/getEntitiesByDomain.js +80 -0
  142. package/dist/tools/getEntitiesByDomain.js.map +1 -0
  143. package/dist/tools/getState.d.ts +8 -0
  144. package/dist/tools/getState.d.ts.map +1 -0
  145. package/dist/tools/getState.js +90 -0
  146. package/dist/tools/getState.js.map +1 -0
  147. package/dist/tools/getStates.d.ts +8 -0
  148. package/dist/tools/getStates.d.ts.map +1 -0
  149. package/dist/tools/getStates.js +70 -0
  150. package/dist/tools/getStates.js.map +1 -0
  151. package/dist/tools/index.d.ts +9 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +9 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/listClients.d.ts +8 -0
  156. package/dist/tools/listClients.d.ts.map +1 -0
  157. package/dist/tools/listClients.js +73 -0
  158. package/dist/tools/listClients.js.map +1 -0
  159. package/dist/tools/listDevices.d.ts +8 -0
  160. package/dist/tools/listDevices.d.ts.map +1 -0
  161. package/dist/tools/listDevices.js +73 -0
  162. package/dist/tools/listDevices.js.map +1 -0
  163. package/dist/tools/listSites.d.ts +8 -0
  164. package/dist/tools/listSites.d.ts.map +1 -0
  165. package/dist/tools/listSites.js +67 -0
  166. package/dist/tools/listSites.js.map +1 -0
  167. package/dist/tools/registry.d.ts +9 -0
  168. package/dist/tools/registry.d.ts.map +1 -0
  169. package/dist/tools/registry.js +1382 -0
  170. package/dist/tools/registry.js.map +1 -0
  171. package/dist/tools/searchDevices.d.ts +8 -0
  172. package/dist/tools/searchDevices.d.ts.map +1 -0
  173. package/dist/tools/searchDevices.js +84 -0
  174. package/dist/tools/searchDevices.js.map +1 -0
  175. package/dist/tools/searchEntities.d.ts +8 -0
  176. package/dist/tools/searchEntities.d.ts.map +1 -0
  177. package/dist/tools/searchEntities.js +80 -0
  178. package/dist/tools/searchEntities.js.map +1 -0
  179. package/dist/types/entity.d.ts +98 -0
  180. package/dist/types/entity.d.ts.map +1 -0
  181. package/dist/types/entity.js +2 -0
  182. package/dist/types/entity.js.map +1 -0
  183. package/dist/types/errors.d.ts +16 -0
  184. package/dist/types/errors.d.ts.map +1 -0
  185. package/dist/types/errors.js +32 -0
  186. package/dist/types/errors.js.map +1 -0
  187. package/dist/types/haConfig.d.ts +8 -0
  188. package/dist/types/haConfig.d.ts.map +1 -0
  189. package/dist/types/haConfig.js +2 -0
  190. package/dist/types/haConfig.js.map +1 -0
  191. package/dist/types/index.d.ts +6 -0
  192. package/dist/types/index.d.ts.map +1 -0
  193. package/dist/types/index.js +2 -0
  194. package/dist/types/index.js.map +1 -0
  195. package/dist/types/request.d.ts +7 -0
  196. package/dist/types/request.d.ts.map +1 -0
  197. package/dist/types/request.js +2 -0
  198. package/dist/types/request.js.map +1 -0
  199. package/dist/types/serviceCall.d.ts +18 -0
  200. package/dist/types/serviceCall.d.ts.map +1 -0
  201. package/dist/types/serviceCall.js +2 -0
  202. package/dist/types/serviceCall.js.map +1 -0
  203. package/dist/types.d.ts +2 -0
  204. package/dist/types.d.ts.map +1 -0
  205. package/dist/types.js +3 -0
  206. package/dist/types.js.map +1 -0
  207. package/dist/utils/config-validations.d.ts +12 -0
  208. package/dist/utils/config-validations.d.ts.map +1 -0
  209. package/dist/utils/config-validations.js +43 -0
  210. package/dist/utils/config-validations.js.map +1 -0
  211. package/dist/utils/logger.d.ts +24 -0
  212. package/dist/utils/logger.d.ts.map +1 -0
  213. package/dist/utils/logger.js +94 -0
  214. package/dist/utils/logger.js.map +1 -0
  215. package/dist/wrapper.d.ts +8 -0
  216. package/dist/wrapper.d.ts.map +1 -0
  217. package/dist/wrapper.js +301 -0
  218. package/dist/wrapper.js.map +1 -0
  219. package/docker-compose.yml +17 -0
  220. package/docs/CHANGELOG.md +86 -0
  221. package/docs/LOCAL_CLIENT.md +268 -0
  222. package/docs/SSE_API.md +341 -0
  223. package/eslint.config.js +33 -0
  224. package/n8n/ai-agent-safety-monitor.json +355 -0
  225. package/openapi.json +166 -0
  226. package/package.json +51 -0
  227. package/src/config.ts +260 -0
  228. package/src/haClient/automations.ts +217 -0
  229. package/src/haClient/config.ts +41 -0
  230. package/src/haClient/devices.ts +653 -0
  231. package/src/haClient/entities.ts +128 -0
  232. package/src/haClient/events.ts +456 -0
  233. package/src/haClient/history.ts +68 -0
  234. package/src/haClient/index.ts +287 -0
  235. package/src/haClient/request.ts +127 -0
  236. package/src/haClient/services.ts +45 -0
  237. package/src/haClient/states.ts +60 -0
  238. package/src/haClient/updates.ts +58 -0
  239. package/src/index.ts +99 -0
  240. package/src/localAI/index.ts +88 -0
  241. package/src/localAI/prompts.ts +50 -0
  242. package/src/localAI/providers/base.ts +120 -0
  243. package/src/localAI/providers/ollama.ts +78 -0
  244. package/src/localAI/providers/openai.ts +86 -0
  245. package/src/localAI/types.ts +67 -0
  246. package/src/resources/index.ts +266 -0
  247. package/src/server/common.ts +38 -0
  248. package/src/server/eventSubscription.ts +318 -0
  249. package/src/server/http.ts +546 -0
  250. package/src/server/rateLimiter.ts +195 -0
  251. package/src/server/sse.ts +120 -0
  252. package/src/server/stdio.ts +22 -0
  253. package/src/server/stream.ts +113 -0
  254. package/src/tools/callService.ts +114 -0
  255. package/src/tools/getEntitiesByDomain.ts +92 -0
  256. package/src/tools/getState.ts +103 -0
  257. package/src/tools/getStates.ts +80 -0
  258. package/src/tools/index.ts +9 -0
  259. package/src/tools/registry.ts +1521 -0
  260. package/src/tools/searchEntities.ts +92 -0
  261. package/src/types/entity.ts +110 -0
  262. package/src/types/errors.ts +33 -0
  263. package/src/types/haConfig.ts +7 -0
  264. package/src/types/index.ts +5 -0
  265. package/src/types/request.ts +6 -0
  266. package/src/types/serviceCall.ts +18 -0
  267. package/src/types.ts +2 -0
  268. package/src/utils/config-validations.ts +46 -0
  269. package/src/utils/logger.ts +114 -0
  270. package/src/wrapper.ts +340 -0
  271. package/tests/config.test.ts +332 -0
  272. package/tests/haClient/automations.test.ts +133 -0
  273. package/tests/haClient/config.test.ts +96 -0
  274. package/tests/haClient/entities.test.ts +188 -0
  275. package/tests/haClient/history.test.ts +170 -0
  276. package/tests/haClient/index.test.ts +221 -0
  277. package/tests/haClient/request.test.ts +217 -0
  278. package/tests/haClient/services.test.ts +120 -0
  279. package/tests/haClient/states.test.ts +119 -0
  280. package/tests/haClient/updates.test.ts +193 -0
  281. package/tests/index.test.ts +164 -0
  282. package/tests/localAI/index.test.ts +168 -0
  283. package/tests/localAI/ollama.test.ts +187 -0
  284. package/tests/localAI/openai.test.ts +257 -0
  285. package/tests/server/common.test.ts +64 -0
  286. package/tests/server/http.test.ts +326 -0
  287. package/tests/server/sse.test.ts +240 -0
  288. package/tests/server/stdio.test.ts +102 -0
  289. package/tests/server/stream.test.ts +287 -0
  290. package/tests/tools/registry.test.ts +355 -0
  291. package/tests/utils/config-validations.test.ts +289 -0
  292. package/tests/utils/logger.test.ts +327 -0
  293. package/tsconfig.json +25 -0
  294. package/vitest.config.ts +26 -0
@@ -0,0 +1,546 @@
1
+ /**
2
+ * HTTP server for MCP with SSE or Streamable transports
3
+ * Includes health check, CORS support, rate limiting, and real-time event subscriptions
4
+ */
5
+
6
+ import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
7
+ import type { EnvironmentConfig } from '../config.js';
8
+ import type { HaClient } from '../haClient/index.js';
9
+ import { EventSubscriber } from '../haClient/events.js';
10
+ import type { LocalAIClient } from '../localAI/index.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { handleSseConnection, handleSseMessage, getSseMessagePath, type SSETransportState } from './sse.js';
13
+ import { handleStreamRequest, type StreamTransportState } from './stream.js';
14
+ import { handleEventSubscription, getClientCount } from './eventSubscription.js';
15
+ import { RateLimiter } from './rateLimiter.js';
16
+
17
+ // Session storage for stateful mode
18
+ const sessions = new Map<string, SSETransportState | StreamTransportState>();
19
+
20
+ /**
21
+ * Parse JSON body from request
22
+ */
23
+ function parseBody(req: IncomingMessage): Promise<unknown> {
24
+ return new Promise((resolve, reject) => {
25
+ let body = '';
26
+ req.on('data', (chunk) => (body += chunk.toString()));
27
+ req.on('end', () => {
28
+ if (!body) {
29
+ resolve(undefined);
30
+ return;
31
+ }
32
+ try {
33
+ resolve(JSON.parse(body));
34
+ } catch (error) {
35
+ reject(new Error(`Invalid JSON: ${(error as Error).message}`));
36
+ }
37
+ });
38
+ req.on('error', reject);
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Send JSON response
44
+ */
45
+ function sendJson(res: ServerResponse, statusCode: number, data: unknown): void {
46
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
47
+ res.end(JSON.stringify(data));
48
+ }
49
+
50
+ /**
51
+ * Handle health check endpoint
52
+ */
53
+ function handleHealthCheck(res: ServerResponse, config: EnvironmentConfig, aiEnabled: boolean, sseEnabled: boolean): void {
54
+ sendJson(res, 200, {
55
+ status: 'healthy',
56
+ version: '0.5.0',
57
+ transport: config.httpTransport,
58
+ stateful: config.stateful,
59
+ aiEnabled,
60
+ aiProvider: aiEnabled ? config.aiProvider : undefined,
61
+ aiUrl: aiEnabled ? config.aiUrl : undefined,
62
+ aiModel: aiEnabled ? config.aiModel : undefined,
63
+ sseEventsEnabled: sseEnabled,
64
+ sseEventsPath: sseEnabled ? config.sseEventsPath : undefined,
65
+ sseConnectedClients: sseEnabled ? getClientCount() : undefined,
66
+ rateLimitEnabled: config.rateLimitEnabled,
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Add CORS headers for REST API endpoints (always enabled for Open WebUI compatibility)
72
+ */
73
+ function addRestApiCors(req: IncomingMessage, res: ServerResponse): void {
74
+ const origin = req.headers.origin;
75
+ res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
76
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
77
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
78
+ res.setHeader('Access-Control-Max-Age', '86400');
79
+ }
80
+
81
+ /**
82
+ * Handle REST API requests for Open WebUI compatibility
83
+ * These endpoints translate REST calls to Home Assistant API calls
84
+ */
85
+ async function handleRestApi(
86
+ req: IncomingMessage,
87
+ res: ServerResponse,
88
+ url: string,
89
+ client: HaClient
90
+ ): Promise<void> {
91
+ // Always add CORS for REST API
92
+ addRestApiCors(req, res);
93
+
94
+ const parsedUrl = new URL(url, `http://${req.headers.host}`);
95
+ const pathname = parsedUrl.pathname;
96
+
97
+ try {
98
+ // GET /api/states - Get all entity states
99
+ if (req.method === 'GET' && pathname === '/api/states') {
100
+ const states = await client.getStates();
101
+ sendJson(res, 200, states);
102
+ return;
103
+ }
104
+
105
+ // GET /api/states/{entity_id} - Get specific entity state
106
+ const stateMatch = pathname.match(/^\/api\/states\/([^/]+)$/);
107
+ if (req.method === 'GET' && stateMatch) {
108
+ const entityId = decodeURIComponent(stateMatch[1]);
109
+ const state = await client.getState(entityId);
110
+ if (state) {
111
+ sendJson(res, 200, state);
112
+ } else {
113
+ sendJson(res, 404, { error: `Entity ${entityId} not found` });
114
+ }
115
+ return;
116
+ }
117
+
118
+ // GET /api/sensors - Get all sensors
119
+ if (req.method === 'GET' && pathname === '/api/sensors') {
120
+ const states = await client.getStates();
121
+ const sensors = states.filter(
122
+ (s: { entity_id: string }) =>
123
+ s.entity_id.startsWith('sensor.') || s.entity_id.startsWith('binary_sensor.')
124
+ );
125
+ sendJson(res, 200, sensors);
126
+ return;
127
+ }
128
+
129
+ // GET /api/entities/{domain} - Get entities by domain
130
+ const domainMatch = pathname.match(/^\/api\/entities\/([^/]+)$/);
131
+ if (req.method === 'GET' && domainMatch) {
132
+ const domain = decodeURIComponent(domainMatch[1]);
133
+ const states = await client.getStates();
134
+ const filtered = states.filter((s: { entity_id: string }) =>
135
+ s.entity_id.startsWith(`${domain}.`)
136
+ );
137
+ sendJson(res, 200, filtered);
138
+ return;
139
+ }
140
+
141
+ // GET /api/search?q={query} - Search entities
142
+ if (req.method === 'GET' && pathname === '/api/search') {
143
+ const query = parsedUrl.searchParams.get('q')?.toLowerCase() ?? '';
144
+ const states = await client.getStates();
145
+ const filtered = states.filter(
146
+ (s: { entity_id: string; attributes?: { friendly_name?: string } }) =>
147
+ s.entity_id.toLowerCase().includes(query) ||
148
+ s.attributes?.friendly_name?.toLowerCase().includes(query)
149
+ );
150
+ sendJson(res, 200, filtered);
151
+ return;
152
+ }
153
+
154
+ // GET /api/history/{entity_id}?hours={hours} - Get entity history
155
+ const historyMatch = pathname.match(/^\/api\/history\/([^/]+)$/);
156
+ if (req.method === 'GET' && historyMatch) {
157
+ const entityId = decodeURIComponent(historyMatch[1]);
158
+ const hours = parseInt(parsedUrl.searchParams.get('hours') ?? '24', 10);
159
+ const history = await client.getHistory(entityId, hours);
160
+ sendJson(res, 200, history);
161
+ return;
162
+ }
163
+
164
+ // POST /api/services/{domain}/{service} - Call a service
165
+ const serviceMatch = pathname.match(/^\/api\/services\/([^/]+)\/([^/]+)$/);
166
+ if (req.method === 'POST' && serviceMatch) {
167
+ const domain = decodeURIComponent(serviceMatch[1]);
168
+ const service = decodeURIComponent(serviceMatch[2]);
169
+ const body = (await parseBody(req)) as { entity_id?: string; data?: Record<string, unknown> } | undefined;
170
+ const result = await client.callService({
171
+ domain,
172
+ service,
173
+ target: body?.entity_id ? { entity_id: body.entity_id } : undefined,
174
+ service_data: body?.data,
175
+ });
176
+ sendJson(res, 200, result);
177
+ return;
178
+ }
179
+
180
+ // Not found
181
+ sendJson(res, 404, { error: 'API endpoint not found' });
182
+ } catch (error) {
183
+ logger.error('REST API error', { error, url });
184
+ sendJson(res, 500, {
185
+ error: 'Internal server error',
186
+ message: error instanceof Error ? error.message : String(error),
187
+ });
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Generate OpenAPI spec for Open WebUI compatibility
193
+ */
194
+ function getOpenApiSpec(baseUrl: string): object {
195
+ return {
196
+ openapi: '3.1.0',
197
+ info: {
198
+ title: 'Home Assistant MCP Tools',
199
+ description: 'MCP server for Home Assistant - exposes entity states and service calls',
200
+ version: '0.2.0',
201
+ },
202
+ servers: [{ url: baseUrl }],
203
+ paths: {
204
+ '/api/states': {
205
+ get: {
206
+ operationId: 'getStates',
207
+ summary: 'Get all entity states from Home Assistant',
208
+ responses: {
209
+ '200': {
210
+ description: 'List of all entity states',
211
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
212
+ },
213
+ },
214
+ },
215
+ },
216
+ '/api/states/{entity_id}': {
217
+ get: {
218
+ operationId: 'getState',
219
+ summary: 'Get state of a specific entity',
220
+ parameters: [{ name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID (e.g., light.living_room)' }],
221
+ responses: {
222
+ '200': {
223
+ description: 'Entity state',
224
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/EntityState' } } },
225
+ },
226
+ },
227
+ },
228
+ },
229
+ '/api/services/{domain}/{service}': {
230
+ post: {
231
+ operationId: 'callService',
232
+ summary: 'Call a Home Assistant service',
233
+ parameters: [
234
+ { name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Service domain (e.g., light, switch)' },
235
+ { name: 'service', in: 'path', required: true, schema: { type: 'string' }, description: 'Service name (e.g., turn_on, turn_off)' },
236
+ ],
237
+ requestBody: {
238
+ content: {
239
+ 'application/json': {
240
+ schema: {
241
+ type: 'object',
242
+ properties: {
243
+ entity_id: { type: 'string', description: 'Target entity ID' },
244
+ data: { type: 'object', description: 'Service data' },
245
+ },
246
+ },
247
+ },
248
+ },
249
+ },
250
+ responses: { '200': { description: 'Service call result' } },
251
+ },
252
+ },
253
+ '/api/sensors': {
254
+ get: {
255
+ operationId: 'getAllSensors',
256
+ summary: 'Get all sensor and binary_sensor states',
257
+ responses: {
258
+ '200': {
259
+ description: 'List of sensor states',
260
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
261
+ },
262
+ },
263
+ },
264
+ },
265
+ '/api/entities/{domain}': {
266
+ get: {
267
+ operationId: 'getEntitiesByDomain',
268
+ summary: 'Get all entities for a specific domain',
269
+ parameters: [{ name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Domain name (e.g., light, sensor)' }],
270
+ responses: {
271
+ '200': {
272
+ description: 'List of entities in domain',
273
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
274
+ },
275
+ },
276
+ },
277
+ },
278
+ '/api/search': {
279
+ get: {
280
+ operationId: 'searchEntities',
281
+ summary: 'Search entities by name or ID',
282
+ parameters: [{ name: 'q', in: 'query', required: true, schema: { type: 'string' }, description: 'Search query' }],
283
+ responses: {
284
+ '200': {
285
+ description: 'Matching entities',
286
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
287
+ },
288
+ },
289
+ },
290
+ },
291
+ '/api/history/{entity_id}': {
292
+ get: {
293
+ operationId: 'getHistory',
294
+ summary: 'Get historical data for an entity',
295
+ parameters: [
296
+ { name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID' },
297
+ { name: 'hours', in: 'query', schema: { type: 'number', default: 24 }, description: 'Hours of history' },
298
+ ],
299
+ responses: {
300
+ '200': {
301
+ description: 'Historical data',
302
+ content: { 'application/json': { schema: { type: 'array' } } },
303
+ },
304
+ },
305
+ },
306
+ },
307
+ },
308
+ components: {
309
+ schemas: {
310
+ EntityState: {
311
+ type: 'object',
312
+ properties: {
313
+ entity_id: { type: 'string' },
314
+ state: { type: 'string' },
315
+ attributes: { type: 'object' },
316
+ last_changed: { type: 'string' },
317
+ last_updated: { type: 'string' },
318
+ },
319
+ },
320
+ },
321
+ },
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Handle CORS preflight
327
+ */
328
+ function handleCors(req: IncomingMessage, res: ServerResponse, config: EnvironmentConfig): void {
329
+ if (!config.httpAllowCors) {
330
+ return;
331
+ }
332
+
333
+ const origin = req.headers.origin;
334
+ const allowedOrigins = config.httpAllowedOrigins ?? [];
335
+
336
+ // If origins list is empty, allow all (wildcard was used)
337
+ if (allowedOrigins.length === 0 || (origin && allowedOrigins.includes(origin))) {
338
+ res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
339
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
340
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
341
+ res.setHeader('Access-Control-Max-Age', '86400');
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Start HTTP server
347
+ */
348
+ export async function startHttpServer(client: HaClient, config: EnvironmentConfig, aiClient?: LocalAIClient): Promise<void> {
349
+ const port = config.httpPort ?? 3000;
350
+ const bindAddr = config.httpBindAddr ?? '127.0.0.1';
351
+ const mcpPath = config.httpPath ?? '/mcp';
352
+ const healthPath = config.httpHealthcheckPath ?? '/health';
353
+ const sseEventsPath = config.sseEventsPath ?? '/subscribe_events';
354
+
355
+ // Initialize rate limiter if enabled
356
+ const rateLimiter = config.rateLimitEnabled
357
+ ? new RateLimiter({
358
+ windowMs: config.rateLimitWindowMs,
359
+ maxRequests: config.rateLimitMaxRequests,
360
+ skipPaths: [healthPath, '/openapi.json'],
361
+ })
362
+ : null;
363
+
364
+ // Initialize event subscriber if SSE events are enabled
365
+ let eventSubscriber: EventSubscriber | null = null;
366
+ if (config.sseEventsEnabled) {
367
+ eventSubscriber = new EventSubscriber({
368
+ baseUrl: config.baseUrl,
369
+ token: config.token,
370
+ });
371
+
372
+ // Connect to Home Assistant WebSocket
373
+ try {
374
+ await eventSubscriber.connect();
375
+ logger.info('Event subscriber connected to Home Assistant');
376
+ } catch (error) {
377
+ logger.warn('Failed to connect event subscriber, SSE events will be unavailable', {
378
+ error: error instanceof Error ? error.message : String(error),
379
+ });
380
+ eventSubscriber = null;
381
+ }
382
+ }
383
+
384
+ logger.info('Starting HTTP server', {
385
+ port,
386
+ bindAddr,
387
+ transport: config.httpTransport,
388
+ mcpPath,
389
+ healthPath,
390
+ sseEventsPath: config.sseEventsEnabled ? sseEventsPath : undefined,
391
+ stateful: config.stateful,
392
+ aiEnabled: !!aiClient,
393
+ rateLimitEnabled: config.rateLimitEnabled,
394
+ sseEventsEnabled: config.sseEventsEnabled && !!eventSubscriber,
395
+ });
396
+
397
+ const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
398
+ const url = req.url ?? '/';
399
+ const urlPath = url.split('?')[0];
400
+
401
+ // Handle OPTIONS (CORS preflight) - add CORS headers for REST API endpoints
402
+ if (req.method === 'OPTIONS') {
403
+ if (url === '/openapi.json' || url.startsWith('/api/') || urlPath === sseEventsPath) {
404
+ addRestApiCors(req, res);
405
+ } else {
406
+ handleCors(req, res, config);
407
+ }
408
+ res.writeHead(204);
409
+ res.end();
410
+ return;
411
+ }
412
+
413
+ // Apply rate limiting (skip health checks and certain paths)
414
+ if (rateLimiter) {
415
+ const allowed = rateLimiter.middleware()(req, res);
416
+ if (!allowed) {
417
+ return; // Response already sent by rate limiter
418
+ }
419
+ }
420
+
421
+ // Add CORS headers for MCP endpoints
422
+ handleCors(req, res, config);
423
+
424
+ try {
425
+ // Health check endpoint
426
+ if (config.httpEnableHealthcheck && url === healthPath) {
427
+ handleHealthCheck(res, config, !!aiClient, !!eventSubscriber);
428
+ return;
429
+ }
430
+
431
+ // SSE Event Subscription endpoint
432
+ if (config.sseEventsEnabled && eventSubscriber && urlPath === sseEventsPath) {
433
+ if (req.method === 'GET') {
434
+ addRestApiCors(req, res);
435
+ await handleEventSubscription(req, res, eventSubscriber);
436
+ return;
437
+ }
438
+ sendJson(res, 405, { error: 'Method not allowed. Use GET for SSE subscription.' });
439
+ return;
440
+ }
441
+
442
+ // OpenAPI spec endpoint for Open WebUI compatibility
443
+ if (req.method === 'GET' && url === '/openapi.json') {
444
+ addRestApiCors(req, res);
445
+ const baseUrl = `http://${req.headers.host ?? `${bindAddr}:${port}`}`;
446
+ sendJson(res, 200, getOpenApiSpec(baseUrl));
447
+ return;
448
+ }
449
+
450
+ // REST API endpoints for Open WebUI compatibility
451
+ if (url.startsWith('/api/')) {
452
+ await handleRestApi(req, res, url, client);
453
+ return;
454
+ }
455
+
456
+ // SSE Transport
457
+ if (config.httpTransport === 'sse') {
458
+ const messagePath = getSseMessagePath();
459
+
460
+ // SSE connection (GET)
461
+ if (req.method === 'GET' && url === mcpPath) {
462
+ const state = await handleSseConnection(client, config, mcpPath, req, res, aiClient);
463
+ if (config.stateful && state.transport.sessionId) {
464
+ sessions.set(state.transport.sessionId, state);
465
+ }
466
+ return;
467
+ }
468
+
469
+ // SSE message (POST)
470
+ if (req.method === 'POST' && url === messagePath) {
471
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
472
+ const state = sessionId ? sessions.get(sessionId) as SSETransportState | undefined : undefined;
473
+
474
+ if (!state || !('sessionId' in state.transport)) {
475
+ sendJson(res, 400, { error: 'Invalid or missing session' });
476
+ return;
477
+ }
478
+
479
+ const body = await parseBody(req);
480
+ await handleSseMessage(state.transport, req, res, body);
481
+ return;
482
+ }
483
+ }
484
+
485
+ // Streamable HTTP Transport
486
+ if (config.httpTransport === 'stream') {
487
+ if (url === mcpPath) {
488
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
489
+ const existingState = sessionId ? sessions.get(sessionId) : undefined;
490
+
491
+ const body = req.method !== 'GET' ? await parseBody(req) : undefined;
492
+ const state = await handleStreamRequest(client, config, req, res, body, existingState as StreamTransportState, aiClient);
493
+
494
+ if (state && config.stateful && sessionId) {
495
+ sessions.set(sessionId, state);
496
+ }
497
+ return;
498
+ }
499
+ }
500
+
501
+ // Not found
502
+ sendJson(res, 404, { error: 'Not found' });
503
+ } catch (error) {
504
+ logger.error('HTTP request error', {
505
+ error,
506
+ method: req.method,
507
+ url,
508
+ });
509
+
510
+ if (!res.headersSent) {
511
+ sendJson(res, 500, {
512
+ error: 'Internal server error',
513
+ message: error instanceof Error ? error.message : String(error),
514
+ });
515
+ }
516
+ }
517
+ });
518
+
519
+ // Handle server shutdown
520
+ const cleanup = () => {
521
+ if (eventSubscriber) {
522
+ eventSubscriber.disconnect();
523
+ }
524
+ if (rateLimiter) {
525
+ rateLimiter.stop();
526
+ }
527
+ };
528
+
529
+ process.on('SIGTERM', cleanup);
530
+ process.on('SIGINT', cleanup);
531
+
532
+ // Start listening
533
+ await new Promise<void>((resolve, reject) => {
534
+ server.on('error', reject);
535
+ server.listen(port, bindAddr, () => {
536
+ logger.info('HTTP server listening', {
537
+ port,
538
+ bindAddr,
539
+ url: `http://${bindAddr}:${port}${mcpPath}`,
540
+ healthUrl: config.httpEnableHealthcheck ? `http://${bindAddr}:${port}${healthPath}` : undefined,
541
+ sseEventsUrl: config.sseEventsEnabled && eventSubscriber ? `http://${bindAddr}:${port}${sseEventsPath}` : undefined,
542
+ });
543
+ resolve();
544
+ });
545
+ });
546
+ }