@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,266 @@
1
+ /**
2
+ * MCP Resources Registry
3
+ * Provides URI-based access to Home Assistant entities
4
+ */
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import {
8
+ ListResourcesRequestSchema,
9
+ ReadResourceRequestSchema,
10
+ ListResourceTemplatesRequestSchema,
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+ import type { HaClient } from '../haClient/index.js';
13
+ import { logger } from '../utils/logger.js';
14
+
15
+ /**
16
+ * Register all Home Assistant resources with the MCP server
17
+ */
18
+ export function registerAllResources(server: Server, client: HaClient): void {
19
+ logger.debug('Registering MCP resources');
20
+
21
+ // List available resource templates
22
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
23
+ return {
24
+ resourceTemplates: [
25
+ {
26
+ uriTemplate: 'hass://entities/{entity_id}',
27
+ name: 'Entity State',
28
+ description: 'Get the current state of a specific Home Assistant entity',
29
+ mimeType: 'application/json',
30
+ },
31
+ {
32
+ uriTemplate: 'hass://entities/{entity_id}/detailed',
33
+ name: 'Entity Detailed',
34
+ description: 'Get detailed information about an entity including all attributes',
35
+ mimeType: 'application/json',
36
+ },
37
+ {
38
+ uriTemplate: 'hass://entities/domain/{domain}',
39
+ name: 'Domain Entities',
40
+ description: 'Get all entities for a specific domain',
41
+ mimeType: 'application/json',
42
+ },
43
+ {
44
+ uriTemplate: 'hass://search/{query}/{limit}',
45
+ name: 'Search Entities',
46
+ description: 'Search for entities matching a query with result limit',
47
+ mimeType: 'application/json',
48
+ },
49
+ ],
50
+ };
51
+ });
52
+
53
+ // List concrete resources (the static entities resource)
54
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
55
+ return {
56
+ resources: [
57
+ {
58
+ uri: 'hass://entities',
59
+ name: 'All Entities',
60
+ description: 'List all Home Assistant entities grouped by domain',
61
+ mimeType: 'application/json',
62
+ },
63
+ ],
64
+ };
65
+ });
66
+
67
+ // Read resource content
68
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
69
+ const uri = request.params.uri;
70
+ logger.debug('Reading resource', { uri });
71
+
72
+ try {
73
+ // Parse the URI
74
+ const url = new URL(uri);
75
+ const path = url.pathname;
76
+
77
+ // hass://entities - List all entities grouped by domain
78
+ if (uri === 'hass://entities') {
79
+ const entities = await client.getStates();
80
+
81
+ // Group by domain
82
+ const grouped: Record<string, Array<{ entity_id: string; state: string; friendly_name?: string }>> = {};
83
+ for (const entity of entities) {
84
+ const domain = entity.entity_id.split('.')[0];
85
+ if (!grouped[domain]) {
86
+ grouped[domain] = [];
87
+ }
88
+ grouped[domain].push({
89
+ entity_id: entity.entity_id,
90
+ state: entity.state,
91
+ friendly_name: entity.attributes.friendly_name as string | undefined,
92
+ });
93
+ }
94
+
95
+ return {
96
+ contents: [
97
+ {
98
+ uri,
99
+ mimeType: 'application/json',
100
+ text: JSON.stringify({
101
+ total: entities.length,
102
+ domains: Object.keys(grouped).length,
103
+ entities: grouped,
104
+ }, null, 2),
105
+ },
106
+ ],
107
+ };
108
+ }
109
+
110
+ // hass://entities/{entity_id}/detailed - Detailed entity info
111
+ if (path.endsWith('/detailed')) {
112
+ const entityId = path.replace('/detailed', '').replace(/^\//, '');
113
+ const entity = await client.getState(entityId);
114
+
115
+ if (!entity) {
116
+ return {
117
+ contents: [
118
+ {
119
+ uri,
120
+ mimeType: 'application/json',
121
+ text: JSON.stringify({ error: 'Entity not found', entity_id: entityId }),
122
+ },
123
+ ],
124
+ };
125
+ }
126
+
127
+ return {
128
+ contents: [
129
+ {
130
+ uri,
131
+ mimeType: 'application/json',
132
+ text: JSON.stringify({
133
+ entity_id: entity.entity_id,
134
+ state: entity.state,
135
+ attributes: entity.attributes,
136
+ last_changed: entity.last_changed,
137
+ last_updated: entity.last_updated,
138
+ context: entity.context,
139
+ }, null, 2),
140
+ },
141
+ ],
142
+ };
143
+ }
144
+
145
+ // hass://entities/domain/{domain} - Entities by domain
146
+ if (path.startsWith('/domain/')) {
147
+ const domain = path.replace('/domain/', '');
148
+ const entities = await client.getEntitiesByDomain(domain);
149
+
150
+ return {
151
+ contents: [
152
+ {
153
+ uri,
154
+ mimeType: 'application/json',
155
+ text: JSON.stringify({
156
+ domain,
157
+ count: entities.length,
158
+ entities: entities.map(e => ({
159
+ entity_id: e.entity_id,
160
+ state: e.state,
161
+ friendly_name: e.attributes.friendly_name,
162
+ })),
163
+ }, null, 2),
164
+ },
165
+ ],
166
+ };
167
+ }
168
+
169
+ // hass://search/{query}/{limit} - Search entities
170
+ if (path.startsWith('/search/')) {
171
+ const parts = path.replace('/search/', '').split('/');
172
+ const query = decodeURIComponent(parts[0] || '');
173
+ const limit = parseInt(parts[1] || '50', 10);
174
+
175
+ let entities = await client.searchEntities(query);
176
+ if (limit > 0) {
177
+ entities = entities.slice(0, limit);
178
+ }
179
+
180
+ return {
181
+ contents: [
182
+ {
183
+ uri,
184
+ mimeType: 'application/json',
185
+ text: JSON.stringify({
186
+ query,
187
+ limit,
188
+ count: entities.length,
189
+ entities: entities.map(e => ({
190
+ entity_id: e.entity_id,
191
+ state: e.state,
192
+ friendly_name: e.attributes.friendly_name,
193
+ })),
194
+ }, null, 2),
195
+ },
196
+ ],
197
+ };
198
+ }
199
+
200
+ // hass://entities/{entity_id} - Simple entity state
201
+ const entityId = path.replace(/^\//, '');
202
+ if (entityId && !entityId.includes('/')) {
203
+ const entity = await client.getState(entityId);
204
+
205
+ if (!entity) {
206
+ return {
207
+ contents: [
208
+ {
209
+ uri,
210
+ mimeType: 'application/json',
211
+ text: JSON.stringify({ error: 'Entity not found', entity_id: entityId }),
212
+ },
213
+ ],
214
+ };
215
+ }
216
+
217
+ return {
218
+ contents: [
219
+ {
220
+ uri,
221
+ mimeType: 'application/json',
222
+ text: JSON.stringify({
223
+ entity_id: entity.entity_id,
224
+ state: entity.state,
225
+ friendly_name: entity.attributes.friendly_name,
226
+ last_changed: entity.last_changed,
227
+ }, null, 2),
228
+ },
229
+ ],
230
+ };
231
+ }
232
+
233
+ // Unknown resource
234
+ return {
235
+ contents: [
236
+ {
237
+ uri,
238
+ mimeType: 'application/json',
239
+ text: JSON.stringify({ error: 'Unknown resource', uri }),
240
+ },
241
+ ],
242
+ };
243
+
244
+ } catch (error) {
245
+ logger.error('Failed to read resource', {
246
+ uri,
247
+ error: error instanceof Error ? error.message : String(error),
248
+ });
249
+
250
+ return {
251
+ contents: [
252
+ {
253
+ uri,
254
+ mimeType: 'application/json',
255
+ text: JSON.stringify({
256
+ error: 'Failed to read resource',
257
+ message: error instanceof Error ? error.message : String(error),
258
+ }),
259
+ },
260
+ ],
261
+ };
262
+ }
263
+ });
264
+
265
+ logger.info('MCP resources registered successfully', { resourceCount: 5 });
266
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Common MCP server creation
3
+ * Shared across all transport types (stdio, SSE, stream)
4
+ */
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import type { HaClient } from '../haClient/index.js';
8
+ import type { LocalAIClient } from '../localAI/index.js';
9
+ import { registerAllTools } from '../tools/registry.js';
10
+ import { registerAllResources } from '../resources/index.js';
11
+ import { logger } from '../utils/logger.js';
12
+
13
+ export function createServer(client: HaClient, aiClient?: LocalAIClient): Server {
14
+ logger.debug('Creating MCP server instance');
15
+
16
+ const server = new Server(
17
+ {
18
+ name: 'mcp-ha-connect',
19
+ version: '0.4.0',
20
+ },
21
+ {
22
+ capabilities: {
23
+ tools: {},
24
+ resources: {},
25
+ },
26
+ }
27
+ );
28
+
29
+ // Register all Home Assistant tools
30
+ registerAllTools(server, client, aiClient);
31
+
32
+ // Register all Home Assistant resources
33
+ registerAllResources(server, client);
34
+
35
+ logger.debug('MCP server instance created with 15 tools and 5 resources registered');
36
+
37
+ return server;
38
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * SSE Event Subscription Endpoint
3
+ * Allows HTTP clients to subscribe to Home Assistant events via Server-Sent Events
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import type { EventSubscriber, HaEvent } from '../haClient/events.js';
8
+ import { logger } from '../utils/logger.js';
9
+
10
+ export interface SSEClient {
11
+ id: string;
12
+ res: ServerResponse;
13
+ subscriptionIds: string[];
14
+ domain?: string;
15
+ entityId?: string;
16
+ eventTypes?: string[];
17
+ createdAt: Date;
18
+ lastEventAt?: Date;
19
+ eventCount: number;
20
+ }
21
+
22
+ // Store active SSE clients
23
+ const clients = new Map<string, SSEClient>();
24
+
25
+ /**
26
+ * Parse query parameters from URL
27
+ */
28
+ function parseQueryParams(url: string): URLSearchParams {
29
+ const queryIndex = url.indexOf('?');
30
+ if (queryIndex === -1) {
31
+ return new URLSearchParams();
32
+ }
33
+ return new URLSearchParams(url.slice(queryIndex + 1));
34
+ }
35
+
36
+ /**
37
+ * Send SSE event to a client
38
+ */
39
+ function sendSSEEvent(res: ServerResponse, event: string, data: unknown): void {
40
+ if (res.writableEnded) {
41
+ return;
42
+ }
43
+
44
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
45
+ res.write(`event: ${event}\n`);
46
+ res.write(`data: ${payload}\n\n`);
47
+ }
48
+
49
+ /**
50
+ * Send SSE comment (keep-alive)
51
+ */
52
+ function sendSSEComment(res: ServerResponse, comment: string): void {
53
+ if (res.writableEnded) {
54
+ return;
55
+ }
56
+ res.write(`: ${comment}\n\n`);
57
+ }
58
+
59
+ /**
60
+ * Handle SSE subscription request
61
+ */
62
+ export async function handleEventSubscription(
63
+ req: IncomingMessage,
64
+ res: ServerResponse,
65
+ eventSubscriber: EventSubscriber
66
+ ): Promise<void> {
67
+ const clientId = `sse_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
68
+ const params = parseQueryParams(req.url ?? '');
69
+
70
+ // Parse filter parameters
71
+ const domain = params.get('domain') ?? undefined;
72
+ const entityId = params.get('entity_id') ?? undefined;
73
+ const eventTypesParam = params.get('event_types');
74
+ const eventTypes = eventTypesParam ? eventTypesParam.split(',').map(t => t.trim()) : undefined;
75
+ const token = params.get('token');
76
+
77
+ logger.info('New SSE subscription request', {
78
+ clientId,
79
+ domain,
80
+ entityId,
81
+ eventTypes,
82
+ hasToken: !!token,
83
+ });
84
+
85
+ // Set SSE headers
86
+ res.writeHead(200, {
87
+ 'Content-Type': 'text/event-stream',
88
+ 'Cache-Control': 'no-cache',
89
+ 'Connection': 'keep-alive',
90
+ 'Access-Control-Allow-Origin': req.headers.origin ?? '*',
91
+ 'Access-Control-Allow-Credentials': 'true',
92
+ 'X-Accel-Buffering': 'no', // Disable nginx buffering
93
+ });
94
+
95
+ // Create client record
96
+ const client: SSEClient = {
97
+ id: clientId,
98
+ res,
99
+ subscriptionIds: [],
100
+ domain,
101
+ entityId,
102
+ eventTypes,
103
+ createdAt: new Date(),
104
+ eventCount: 0,
105
+ };
106
+ clients.set(clientId, client);
107
+
108
+ // Send initial connection event
109
+ sendSSEEvent(res, 'connected', {
110
+ client_id: clientId,
111
+ message: 'Connected to Home Assistant event stream',
112
+ filters: { domain, entityId, eventTypes },
113
+ timestamp: new Date().toISOString(),
114
+ });
115
+
116
+ // Ensure event subscriber is connected
117
+ if (!eventSubscriber.isConnected()) {
118
+ try {
119
+ await eventSubscriber.connect();
120
+ } catch (error) {
121
+ logger.error('Failed to connect event subscriber', { error });
122
+ sendSSEEvent(res, 'error', {
123
+ error: 'Failed to connect to Home Assistant',
124
+ message: error instanceof Error ? error.message : String(error),
125
+ });
126
+ res.end();
127
+ clients.delete(clientId);
128
+ return;
129
+ }
130
+ }
131
+
132
+ // Create event callback
133
+ const eventCallback = (event: HaEvent) => {
134
+ // Apply filters
135
+ if (eventTypes && !eventTypes.includes(event.event_type)) {
136
+ return;
137
+ }
138
+
139
+ if (domain && event.event_type === 'state_changed') {
140
+ const eventDomain = event.data.entity_id?.split('.')[0];
141
+ if (eventDomain !== domain) {
142
+ return;
143
+ }
144
+ }
145
+
146
+ if (entityId && event.data.entity_id !== entityId) {
147
+ return;
148
+ }
149
+
150
+ // Update client stats
151
+ client.lastEventAt = new Date();
152
+ client.eventCount++;
153
+
154
+ // Send event to client
155
+ sendSSEEvent(res, event.event_type, {
156
+ event_type: event.event_type,
157
+ entity_id: event.data.entity_id,
158
+ old_state: event.data.old_state,
159
+ new_state: event.data.new_state,
160
+ domain: event.data.domain,
161
+ service: event.data.service,
162
+ service_data: event.data.service_data,
163
+ time_fired: event.time_fired,
164
+ context: event.context,
165
+ });
166
+ };
167
+
168
+ // Subscribe to events based on filters
169
+ try {
170
+ let subscriptionId: string;
171
+
172
+ if (entityId) {
173
+ // Subscribe to specific entity
174
+ subscriptionId = await eventSubscriber.subscribeEntity(entityId, eventCallback);
175
+ } else if (domain) {
176
+ // Subscribe to domain
177
+ subscriptionId = await eventSubscriber.subscribeDomain(domain, eventCallback);
178
+ } else if (eventTypes && eventTypes.length === 1) {
179
+ // Subscribe to specific event type
180
+ subscriptionId = await eventSubscriber.subscribeEventType(eventTypes[0], eventCallback);
181
+ } else {
182
+ // Subscribe to all state_changed events by default
183
+ subscriptionId = await eventSubscriber.subscribeEventType('state_changed', eventCallback);
184
+ }
185
+
186
+ client.subscriptionIds.push(subscriptionId);
187
+
188
+ logger.info('SSE client subscribed to events', {
189
+ clientId,
190
+ subscriptionId,
191
+ });
192
+ } catch (error) {
193
+ logger.error('Failed to subscribe to events', { error, clientId });
194
+ sendSSEEvent(res, 'error', {
195
+ error: 'Failed to subscribe to events',
196
+ message: error instanceof Error ? error.message : String(error),
197
+ });
198
+ }
199
+
200
+ // Keep-alive ping every 30 seconds
201
+ const keepAliveInterval = setInterval(() => {
202
+ if (res.writableEnded) {
203
+ clearInterval(keepAliveInterval);
204
+ return;
205
+ }
206
+ sendSSEComment(res, `keepalive ${new Date().toISOString()}`);
207
+ }, 30000);
208
+
209
+ // Handle client disconnect
210
+ req.on('close', () => {
211
+ clearInterval(keepAliveInterval);
212
+
213
+ // Unsubscribe from all events
214
+ for (const subId of client.subscriptionIds) {
215
+ eventSubscriber.unsubscribe(subId).catch(error => {
216
+ logger.error('Failed to unsubscribe', { error, subscriptionId: subId });
217
+ });
218
+ }
219
+
220
+ clients.delete(clientId);
221
+
222
+ logger.info('SSE client disconnected', {
223
+ clientId,
224
+ eventCount: client.eventCount,
225
+ duration: Date.now() - client.createdAt.getTime(),
226
+ });
227
+ });
228
+
229
+ // Handle errors
230
+ req.on('error', (error) => {
231
+ logger.error('SSE request error', { error, clientId });
232
+ clearInterval(keepAliveInterval);
233
+ clients.delete(clientId);
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Get list of active SSE clients
239
+ */
240
+ export function getActiveClients(): Array<{
241
+ id: string;
242
+ domain?: string;
243
+ entityId?: string;
244
+ eventTypes?: string[];
245
+ createdAt: Date;
246
+ lastEventAt?: Date;
247
+ eventCount: number;
248
+ }> {
249
+ return Array.from(clients.values()).map(client => ({
250
+ id: client.id,
251
+ domain: client.domain,
252
+ entityId: client.entityId,
253
+ eventTypes: client.eventTypes,
254
+ createdAt: client.createdAt,
255
+ lastEventAt: client.lastEventAt,
256
+ eventCount: client.eventCount,
257
+ }));
258
+ }
259
+
260
+ /**
261
+ * Get count of active SSE clients
262
+ */
263
+ export function getClientCount(): number {
264
+ return clients.size;
265
+ }
266
+
267
+ /**
268
+ * Broadcast event to all matching clients
269
+ */
270
+ export function broadcastEvent(event: string, data: unknown, filter?: {
271
+ domain?: string;
272
+ entityId?: string;
273
+ }): void {
274
+ for (const client of clients.values()) {
275
+ // Apply filter
276
+ if (filter?.domain && client.domain !== filter.domain) {
277
+ continue;
278
+ }
279
+ if (filter?.entityId && client.entityId !== filter.entityId) {
280
+ continue;
281
+ }
282
+
283
+ sendSSEEvent(client.res, event, data);
284
+ client.eventCount++;
285
+ client.lastEventAt = new Date();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Disconnect a specific client
291
+ */
292
+ export function disconnectClient(clientId: string): boolean {
293
+ const client = clients.get(clientId);
294
+ if (!client) {
295
+ return false;
296
+ }
297
+
298
+ sendSSEEvent(client.res, 'disconnect', { reason: 'Server initiated disconnect' });
299
+ client.res.end();
300
+ clients.delete(clientId);
301
+
302
+ return true;
303
+ }
304
+
305
+ /**
306
+ * Disconnect all clients
307
+ */
308
+ export function disconnectAllClients(): number {
309
+ const count = clients.size;
310
+
311
+ for (const client of clients.values()) {
312
+ sendSSEEvent(client.res, 'disconnect', { reason: 'Server shutdown' });
313
+ client.res.end();
314
+ }
315
+
316
+ clients.clear();
317
+ return count;
318
+ }