@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,287 @@
1
+ /**
2
+ * Streamable HTTP transport tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import type { HaClient } from '../../src/haClient/index.js';
7
+ import type { EnvironmentConfig } from '../../src/config.js';
8
+
9
+ // Mock dependencies
10
+ vi.mock('@modelcontextprotocol/sdk/server/streamable.js');
11
+ vi.mock('../../src/server/common.js');
12
+ vi.mock('../../src/utils/logger.js');
13
+
14
+ describe('Stream Transport', () => {
15
+ let mockClient: HaClient;
16
+ let mockConfig: EnvironmentConfig;
17
+
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+
21
+ mockClient = {
22
+ checkApi: vi.fn().mockResolvedValue({ message: 'API running.' }),
23
+ getStates: vi.fn(),
24
+ getState: vi.fn(),
25
+ callService: vi.fn(),
26
+ getEntitiesByDomain: vi.fn(),
27
+ searchEntities: vi.fn(),
28
+ } as any;
29
+
30
+ mockConfig = {
31
+ baseUrl: 'http://homeassistant.10.0.0.19.nip.io:8123',
32
+ token: 'test-token',
33
+ strictSsl: false,
34
+ timeout: 30000,
35
+ aiProvider: 'ollama',
36
+ aiUrl: 'http://ollama.10.0.0.17.nip.io:11434',
37
+ aiModel: 'qwen3:14b',
38
+ aiTimeout: 60000,
39
+ logLevel: 'info',
40
+ logFormat: 'plain',
41
+ useHttp: true,
42
+ stateful: false,
43
+ httpPort: 3000,
44
+ httpTransport: 'stream',
45
+ httpBindAddr: '127.0.0.1',
46
+ httpPath: '/mcp',
47
+ httpEnableHealthcheck: true,
48
+ httpHealthcheckPath: '/health',
49
+ httpAllowCors: true,
50
+ httpAllowedOrigins: ['127.0.0.1', 'localhost'],
51
+ };
52
+ });
53
+
54
+ describe('Stream Connection', () => {
55
+ it('should create streamable HTTP transport', () => {
56
+ expect(mockConfig.httpTransport).toBe('stream');
57
+ expect(mockConfig.httpPath).toBe('/mcp');
58
+ });
59
+
60
+ it('should support stateless mode by default', () => {
61
+ expect(mockConfig.stateful).toBe(false);
62
+ // Each request creates a new transport
63
+ });
64
+
65
+ it('should support stateful mode when enabled', () => {
66
+ const statefulConfig = { ...mockConfig, stateful: true };
67
+
68
+ expect(statefulConfig.stateful).toBe(true);
69
+ // Reuses existing transport for session
70
+ });
71
+
72
+ it('should use modern MCP protocol version', () => {
73
+ const protocolVersion = '2025-03-26';
74
+
75
+ expect(protocolVersion).toBe('2025-03-26');
76
+ // This is the modern streamable HTTP protocol version
77
+ });
78
+ });
79
+
80
+ describe('Request Handling', () => {
81
+ it('should handle GET requests', () => {
82
+ const method = 'GET';
83
+
84
+ expect(method).toBe('GET');
85
+ // Stream transport supports GET for initial connection
86
+ });
87
+
88
+ it('should handle POST requests', () => {
89
+ const method = 'POST';
90
+
91
+ expect(method).toBe('POST');
92
+ // Stream transport supports POST for messages
93
+ });
94
+
95
+ it('should parse request body for non-GET requests', () => {
96
+ const method: string = 'POST';
97
+ const shouldParseBody = method !== 'GET';
98
+
99
+ expect(shouldParseBody).toBe(true);
100
+ });
101
+
102
+ it('should not parse body for GET requests', () => {
103
+ const method: string = 'GET';
104
+ const shouldParseBody = method !== 'GET';
105
+
106
+ expect(shouldParseBody).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('Session Management', () => {
111
+ it('should create new transport for stateless mode', () => {
112
+ expect(mockConfig.stateful).toBe(false);
113
+
114
+ const existingTransport = undefined;
115
+ const shouldCreateNew = !existingTransport;
116
+
117
+ expect(shouldCreateNew).toBe(true);
118
+ });
119
+
120
+ it('should reuse transport for stateful mode', () => {
121
+ const statefulConfig = { ...mockConfig, stateful: true };
122
+ const existingTransport = {
123
+ transport: {},
124
+ server: {},
125
+ };
126
+
127
+ expect(statefulConfig.stateful).toBe(true);
128
+ expect(existingTransport).toBeDefined();
129
+ // Should use existingTransport
130
+ });
131
+
132
+ it('should read session ID from header', () => {
133
+ const headers = {
134
+ 'mcp-session-id': 'session-123',
135
+ };
136
+
137
+ const sessionId = headers['mcp-session-id'];
138
+
139
+ expect(sessionId).toBe('session-123');
140
+ });
141
+
142
+ it('should handle missing session ID', () => {
143
+ const headers = {};
144
+ const sessionId = headers['mcp-session-id' as keyof typeof headers];
145
+
146
+ expect(sessionId).toBeUndefined();
147
+ });
148
+ });
149
+
150
+ describe('Transport Lifecycle', () => {
151
+ it('should connect server on first use', () => {
152
+ const existingTransport = undefined;
153
+ const isFirstConnection = !existingTransport;
154
+
155
+ expect(isFirstConnection).toBe(true);
156
+ // Should call server.connect(transport)
157
+ });
158
+
159
+ it('should skip connection for existing transport', () => {
160
+ const existingTransport = {
161
+ transport: {},
162
+ server: {},
163
+ };
164
+ const isFirstConnection = !existingTransport;
165
+
166
+ expect(isFirstConnection).toBe(false);
167
+ // Should not call server.connect again
168
+ });
169
+
170
+ it('should handle request through transport', () => {
171
+ const request = { method: 'POST', url: '/mcp' };
172
+ const response = {};
173
+ const body = { method: 'tools/call' };
174
+
175
+ expect(request.method).toBe('POST');
176
+ // Should call transport.handleRequest(req, res, body)
177
+ });
178
+ });
179
+
180
+ describe('Stateful vs Stateless', () => {
181
+ it('should return transport state in stateful mode', () => {
182
+ const statefulConfig = { ...mockConfig, stateful: true };
183
+
184
+ expect(statefulConfig.stateful).toBe(true);
185
+ // handleStreamRequest should return state
186
+ });
187
+
188
+ it('should not return state in stateless mode', () => {
189
+ expect(mockConfig.stateful).toBe(false);
190
+ // handleStreamRequest should return void
191
+ });
192
+
193
+ it('should store state in sessions map for stateful mode', () => {
194
+ const sessions = new Map();
195
+ const sessionId = 'session-123';
196
+ const state = {
197
+ transport: {},
198
+ server: {},
199
+ };
200
+
201
+ sessions.set(sessionId, state);
202
+
203
+ expect(sessions.get(sessionId)).toBe(state);
204
+ expect(sessions.size).toBe(1);
205
+ });
206
+ });
207
+
208
+ describe('Error Handling', () => {
209
+ it('should handle transport creation errors', () => {
210
+ const error = new Error('Failed to create transport');
211
+
212
+ expect(error.message).toBe('Failed to create transport');
213
+ // Should propagate error
214
+ });
215
+
216
+ it('should handle request handling errors', () => {
217
+ const error = new Error('Failed to handle request');
218
+
219
+ expect(error.message).toBe('Failed to handle request');
220
+ // Should propagate error
221
+ });
222
+
223
+ it('should handle server connection errors', () => {
224
+ const error = new Error('Failed to connect server');
225
+
226
+ expect(error.message).toBe('Failed to connect server');
227
+ // Should propagate error
228
+ });
229
+ });
230
+
231
+ describe('Transport State Type', () => {
232
+ it('should have transport and server properties', () => {
233
+ const state = {
234
+ transport: { handleRequest: vi.fn() },
235
+ server: { connect: vi.fn() },
236
+ };
237
+
238
+ expect(state.transport).toBeDefined();
239
+ expect(state.server).toBeDefined();
240
+ expect(typeof state.transport.handleRequest).toBe('function');
241
+ expect(typeof state.server.connect).toBe('function');
242
+ });
243
+
244
+ it('should distinguish from SSE transport state', () => {
245
+ const streamState = {
246
+ transport: { handleRequest: vi.fn() },
247
+ server: {},
248
+ };
249
+
250
+ const sseState = {
251
+ transport: { sessionId: 'sse-123' },
252
+ server: {},
253
+ };
254
+
255
+ expect('handleRequest' in streamState.transport).toBe(true);
256
+ expect('sessionId' in sseState.transport).toBe(true);
257
+ expect('sessionId' in streamState.transport).toBe(false);
258
+ });
259
+ });
260
+
261
+ describe('Protocol Differences', () => {
262
+ it('should not use sessionId in transport like SSE does', () => {
263
+ const streamTransport = {
264
+ handleRequest: vi.fn(),
265
+ };
266
+
267
+ expect('sessionId' in streamTransport).toBe(false);
268
+ // Stream transport doesn't embed sessionId in transport object
269
+ });
270
+
271
+ it('should use HTTP headers for session tracking', () => {
272
+ const sessionHeader = 'Mcp-Session-Id';
273
+
274
+ expect(sessionHeader).toBe('Mcp-Session-Id');
275
+ // Stream uses headers, not transport properties
276
+ });
277
+
278
+ it('should support both GET and POST unlike SSE', () => {
279
+ const supportedMethods = ['GET', 'POST'];
280
+
281
+ expect(supportedMethods).toContain('GET');
282
+ expect(supportedMethods).toContain('POST');
283
+ // SSE uses GET for connection, POST for messages
284
+ // Stream uses both for the same endpoint
285
+ });
286
+ });
287
+ });
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Tool registry tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { registerAllTools } from '../../src/tools/registry.js';
8
+ import { HaClient } from '../../src/haClient/index.js';
9
+ import type { EnvironmentConfig } from '../../src/config.js';
10
+ import {
11
+ CallToolRequestSchema,
12
+ ListToolsRequestSchema,
13
+ } from '@modelcontextprotocol/sdk/types.js';
14
+
15
+ describe('Tool Registry', () => {
16
+ let server: Server;
17
+ let mockClient: HaClient;
18
+ let mockConfig: EnvironmentConfig;
19
+
20
+ beforeEach(() => {
21
+ mockConfig = {
22
+ baseUrl: 'http://homeassistant.10.0.0.19.nip.io:8123',
23
+ token: 'test-token',
24
+ strictSsl: false,
25
+ timeout: 30000,
26
+ aiProvider: 'ollama',
27
+ aiUrl: 'http://ollama.10.0.0.17.nip.io:11434',
28
+ aiModel: 'qwen3:14b',
29
+ aiTimeout: 60000,
30
+ logLevel: 'error', // Suppress logs during tests
31
+ logFormat: 'plain',
32
+ useHttp: false,
33
+ stateful: false,
34
+ httpTransport: 'stream',
35
+ httpEnableHealthcheck: true,
36
+ httpAllowCors: true,
37
+ };
38
+
39
+ mockClient = new HaClient(mockConfig);
40
+
41
+ server = new Server(
42
+ {
43
+ name: 'test-server',
44
+ version: '1.0.0',
45
+ },
46
+ {
47
+ capabilities: {
48
+ tools: {},
49
+ },
50
+ }
51
+ );
52
+ });
53
+
54
+ describe('registerAllTools', () => {
55
+ it('should register all tools without errors', () => {
56
+ expect(() => registerAllTools(server, mockClient)).not.toThrow();
57
+ });
58
+
59
+ it('should register ListTools handler', () => {
60
+ registerAllTools(server, mockClient);
61
+
62
+ // The server should have handlers registered
63
+ // We can't directly test private handlers, but we can verify no errors
64
+ expect(server).toBeDefined();
65
+ });
66
+
67
+ it('should register CallTool handler', () => {
68
+ registerAllTools(server, mockClient);
69
+
70
+ // The server should have handlers registered
71
+ expect(server).toBeDefined();
72
+ });
73
+ });
74
+
75
+ describe('Tool List', () => {
76
+ it('should include all 5 tools', () => {
77
+ const expectedTools = [
78
+ 'getStates',
79
+ 'getState',
80
+ 'callService',
81
+ 'getEntitiesByDomain',
82
+ 'searchEntities',
83
+ ];
84
+
85
+ expect(expectedTools).toHaveLength(5);
86
+ });
87
+
88
+ it('should have correct tool names', () => {
89
+ const toolNames = [
90
+ 'getStates',
91
+ 'getState',
92
+ 'callService',
93
+ 'getEntitiesByDomain',
94
+ 'searchEntities',
95
+ ];
96
+
97
+ expect(toolNames).toContain('getStates');
98
+ expect(toolNames).toContain('getState');
99
+ expect(toolNames).toContain('callService');
100
+ expect(toolNames).toContain('getEntitiesByDomain');
101
+ expect(toolNames).toContain('searchEntities');
102
+ });
103
+ });
104
+
105
+ describe('Tool Schemas', () => {
106
+ it('getStates should have no required parameters', () => {
107
+ const schema = {
108
+ type: 'object',
109
+ properties: {},
110
+ required: [],
111
+ };
112
+
113
+ expect(schema.required).toEqual([]);
114
+ });
115
+
116
+ it('getState should require entity_id', () => {
117
+ const schema = {
118
+ type: 'object',
119
+ properties: {
120
+ entity_id: { type: 'string', description: 'Entity ID' },
121
+ },
122
+ required: ['entity_id'],
123
+ };
124
+
125
+ expect(schema.required).toContain('entity_id');
126
+ });
127
+
128
+ it('callService should require domain and service', () => {
129
+ const schema = {
130
+ type: 'object',
131
+ properties: {
132
+ domain: { type: 'string' },
133
+ service: { type: 'string' },
134
+ target: { type: 'object' },
135
+ data: { type: 'object' },
136
+ },
137
+ required: ['domain', 'service'],
138
+ };
139
+
140
+ expect(schema.required).toContain('domain');
141
+ expect(schema.required).toContain('service');
142
+ expect(schema.required).not.toContain('target');
143
+ expect(schema.required).not.toContain('data');
144
+ });
145
+
146
+ it('getEntitiesByDomain should require domain', () => {
147
+ const schema = {
148
+ type: 'object',
149
+ properties: {
150
+ domain: { type: 'string' },
151
+ },
152
+ required: ['domain'],
153
+ };
154
+
155
+ expect(schema.required).toContain('domain');
156
+ });
157
+
158
+ it('searchEntities should require query', () => {
159
+ const schema = {
160
+ type: 'object',
161
+ properties: {
162
+ query: { type: 'string' },
163
+ },
164
+ required: ['query'],
165
+ };
166
+
167
+ expect(schema.required).toContain('query');
168
+ });
169
+ });
170
+
171
+ describe('Tool Descriptions', () => {
172
+ it('should have descriptive names for all tools', () => {
173
+ const toolDescriptions = {
174
+ getStates: 'Get all entity states from Home Assistant',
175
+ getState: 'Get the state of a specific entity',
176
+ callService: 'Call a Home Assistant service',
177
+ getEntitiesByDomain: 'Get all entities for a specific domain',
178
+ searchEntities: 'Search for entities by name or entity_id',
179
+ };
180
+
181
+ expect(toolDescriptions.getStates).toBeDefined();
182
+ expect(toolDescriptions.getState).toBeDefined();
183
+ expect(toolDescriptions.callService).toBeDefined();
184
+ expect(toolDescriptions.getEntitiesByDomain).toBeDefined();
185
+ expect(toolDescriptions.searchEntities).toBeDefined();
186
+ });
187
+ });
188
+
189
+ describe('Error Handling', () => {
190
+ it('should throw error for unknown tool', () => {
191
+ const unknownTool = 'unknownTool';
192
+ const validTools = [
193
+ 'getStates',
194
+ 'getState',
195
+ 'callService',
196
+ 'getEntitiesByDomain',
197
+ 'searchEntities',
198
+ ];
199
+
200
+ expect(validTools).not.toContain(unknownTool);
201
+ // Should throw 'Unknown tool' error
202
+ });
203
+
204
+ it('should validate required parameters', () => {
205
+ // getState without entity_id should fail
206
+ const args = {};
207
+ const hasEntityId = 'entity_id' in args;
208
+
209
+ expect(hasEntityId).toBe(false);
210
+ // Should throw validation error
211
+ });
212
+
213
+ it('should handle client errors gracefully', async () => {
214
+ const error = new Error('API error');
215
+
216
+ // When client throws, should be caught and returned as error
217
+ expect(error).toBeInstanceOf(Error);
218
+ });
219
+ });
220
+
221
+ describe('Tool Execution', () => {
222
+ it('should call client.getStates for getStates tool', async () => {
223
+ const spy = vi.spyOn(mockClient, 'getStates').mockResolvedValue([]);
224
+
225
+ registerAllTools(server, mockClient);
226
+
227
+ // Verify the spy was set up
228
+ expect(spy).toBeDefined();
229
+ });
230
+
231
+ it('should call client.getState for getState tool', async () => {
232
+ const spy = vi.spyOn(mockClient, 'getState').mockResolvedValue(null);
233
+
234
+ registerAllTools(server, mockClient);
235
+
236
+ expect(spy).toBeDefined();
237
+ });
238
+
239
+ it('should call client.callService for callService tool', async () => {
240
+ const spy = vi.spyOn(mockClient, 'callService').mockResolvedValue({
241
+ context: { id: 'test', parent_id: null, user_id: null },
242
+ });
243
+
244
+ registerAllTools(server, mockClient);
245
+
246
+ expect(spy).toBeDefined();
247
+ });
248
+
249
+ it('should call client.getEntitiesByDomain for getEntitiesByDomain tool', async () => {
250
+ const spy = vi.spyOn(mockClient, 'getEntitiesByDomain').mockResolvedValue([]);
251
+
252
+ registerAllTools(server, mockClient);
253
+
254
+ expect(spy).toBeDefined();
255
+ });
256
+
257
+ it('should call client.searchEntities for searchEntities tool', async () => {
258
+ const spy = vi.spyOn(mockClient, 'searchEntities').mockResolvedValue([]);
259
+
260
+ registerAllTools(server, mockClient);
261
+
262
+ expect(spy).toBeDefined();
263
+ });
264
+ });
265
+
266
+ describe('Centralized Registration', () => {
267
+ it('should use single ListTools handler', () => {
268
+ registerAllTools(server, mockClient);
269
+
270
+ // Only one handler should be registered for ListToolsRequestSchema
271
+ // Multiple calls to setRequestHandler would overwrite previous handlers
272
+ expect(server).toBeDefined();
273
+ });
274
+
275
+ it('should use single CallTool handler with switch statement', () => {
276
+ registerAllTools(server, mockClient);
277
+
278
+ // Only one handler for CallToolRequestSchema
279
+ // Uses switch statement to dispatch to correct tool
280
+ expect(server).toBeDefined();
281
+ });
282
+
283
+ it('should prevent handler overwriting', () => {
284
+ // This was the bug that was fixed: multiple setRequestHandler calls
285
+ // would overwrite each other, causing only the last tool to work
286
+ registerAllTools(server, mockClient);
287
+
288
+ // All tools should be accessible through the single handler
289
+ expect(server).toBeDefined();
290
+ });
291
+ });
292
+
293
+ describe('Tool Response Format', () => {
294
+ it('should return array of entities for getStates', () => {
295
+ const response = {
296
+ content: [
297
+ {
298
+ type: 'text',
299
+ text: JSON.stringify([]),
300
+ },
301
+ ],
302
+ };
303
+
304
+ expect(response.content[0].type).toBe('text');
305
+ expect(Array.isArray(JSON.parse(response.content[0].text))).toBe(true);
306
+ });
307
+
308
+ it('should return single entity for getState', () => {
309
+ const entity = {
310
+ entity_id: 'light.living_room',
311
+ state: 'on',
312
+ attributes: {},
313
+ last_changed: '',
314
+ last_updated: '',
315
+ context: { id: '', parent_id: null, user_id: null },
316
+ };
317
+
318
+ const response = {
319
+ content: [
320
+ {
321
+ type: 'text',
322
+ text: JSON.stringify(entity),
323
+ },
324
+ ],
325
+ };
326
+
327
+ expect(response.content[0].type).toBe('text');
328
+ const parsed = JSON.parse(response.content[0].text);
329
+ expect(parsed.entity_id).toBe('light.living_room');
330
+ });
331
+
332
+ it('should return service call result for callService', () => {
333
+ const result = {
334
+ context: {
335
+ id: 'context-123',
336
+ parent_id: null,
337
+ user_id: 'user-456',
338
+ },
339
+ };
340
+
341
+ const response = {
342
+ content: [
343
+ {
344
+ type: 'text',
345
+ text: JSON.stringify(result),
346
+ },
347
+ ],
348
+ };
349
+
350
+ expect(response.content[0].type).toBe('text');
351
+ const parsed = JSON.parse(response.content[0].text);
352
+ expect(parsed.context.id).toBe('context-123');
353
+ });
354
+ });
355
+ });