@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,195 @@
1
+ /**
2
+ * Rate Limiter Middleware
3
+ * Simple token bucket rate limiter for HTTP endpoints
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ export interface RateLimitConfig {
10
+ windowMs?: number; // Time window in milliseconds (default: 60000 = 1 minute)
11
+ maxRequests?: number; // Maximum requests per window (default: 100)
12
+ skipPaths?: string[]; // Paths to skip rate limiting (e.g., health checks)
13
+ keyGenerator?: (req: IncomingMessage) => string; // Custom key generator
14
+ }
15
+
16
+ interface RateLimitEntry {
17
+ count: number;
18
+ windowStart: number;
19
+ }
20
+
21
+ /**
22
+ * Rate Limiter class
23
+ */
24
+ export class RateLimiter {
25
+ private readonly windowMs: number;
26
+ private readonly maxRequests: number;
27
+ private readonly skipPaths: Set<string>;
28
+ private readonly keyGenerator: (req: IncomingMessage) => string;
29
+ private readonly entries = new Map<string, RateLimitEntry>();
30
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
31
+
32
+ constructor(config: RateLimitConfig = {}) {
33
+ this.windowMs = config.windowMs ?? 60000; // 1 minute default
34
+ this.maxRequests = config.maxRequests ?? 100; // 100 requests per minute
35
+ this.skipPaths = new Set(config.skipPaths ?? ['/health', '/healthcheck']);
36
+ this.keyGenerator = config.keyGenerator ?? this.defaultKeyGenerator;
37
+
38
+ // Start cleanup interval to prevent memory leaks
39
+ this.startCleanup();
40
+ }
41
+
42
+ /**
43
+ * Default key generator - uses IP address
44
+ */
45
+ private defaultKeyGenerator(req: IncomingMessage): string {
46
+ // Try to get real IP from common proxy headers
47
+ const forwardedFor = req.headers['x-forwarded-for'];
48
+ if (forwardedFor) {
49
+ const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
50
+ return ips.split(',')[0].trim();
51
+ }
52
+
53
+ const realIp = req.headers['x-real-ip'];
54
+ if (realIp) {
55
+ return Array.isArray(realIp) ? realIp[0] : realIp;
56
+ }
57
+
58
+ // Fall back to socket remote address
59
+ return req.socket.remoteAddress ?? 'unknown';
60
+ }
61
+
62
+ /**
63
+ * Start periodic cleanup of expired entries
64
+ */
65
+ private startCleanup(): void {
66
+ // Cleanup every window period
67
+ this.cleanupInterval = setInterval(() => {
68
+ const now = Date.now();
69
+ let cleaned = 0;
70
+
71
+ for (const [key, entry] of this.entries) {
72
+ if (now - entry.windowStart > this.windowMs * 2) {
73
+ this.entries.delete(key);
74
+ cleaned++;
75
+ }
76
+ }
77
+
78
+ if (cleaned > 0) {
79
+ logger.debug('Rate limiter cleanup', { entriesRemoved: cleaned, remaining: this.entries.size });
80
+ }
81
+ }, this.windowMs);
82
+ }
83
+
84
+ /**
85
+ * Stop cleanup interval
86
+ */
87
+ stop(): void {
88
+ if (this.cleanupInterval) {
89
+ clearInterval(this.cleanupInterval);
90
+ this.cleanupInterval = null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Check if request should be allowed
96
+ */
97
+ check(req: IncomingMessage): { allowed: boolean; remaining: number; resetTime: number } {
98
+ const url = req.url ?? '/';
99
+ const path = url.split('?')[0];
100
+
101
+ // Skip rate limiting for certain paths
102
+ if (this.skipPaths.has(path)) {
103
+ return { allowed: true, remaining: this.maxRequests, resetTime: 0 };
104
+ }
105
+
106
+ const key = this.keyGenerator(req);
107
+ const now = Date.now();
108
+ let entry = this.entries.get(key);
109
+
110
+ // Create new entry or reset if window expired
111
+ if (!entry || now - entry.windowStart > this.windowMs) {
112
+ entry = { count: 0, windowStart: now };
113
+ this.entries.set(key, entry);
114
+ }
115
+
116
+ // Increment count
117
+ entry.count++;
118
+
119
+ const remaining = Math.max(0, this.maxRequests - entry.count);
120
+ const resetTime = entry.windowStart + this.windowMs;
121
+
122
+ if (entry.count > this.maxRequests) {
123
+ logger.warn('Rate limit exceeded', {
124
+ key,
125
+ count: entry.count,
126
+ limit: this.maxRequests,
127
+ path,
128
+ });
129
+ return { allowed: false, remaining: 0, resetTime };
130
+ }
131
+
132
+ return { allowed: true, remaining, resetTime };
133
+ }
134
+
135
+ /**
136
+ * Middleware function for HTTP server
137
+ */
138
+ middleware(): (req: IncomingMessage, res: ServerResponse) => boolean {
139
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
140
+ const result = this.check(req);
141
+
142
+ // Add rate limit headers
143
+ res.setHeader('X-RateLimit-Limit', this.maxRequests.toString());
144
+ res.setHeader('X-RateLimit-Remaining', result.remaining.toString());
145
+ res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000).toString());
146
+
147
+ if (!result.allowed) {
148
+ res.setHeader('Retry-After', Math.ceil((result.resetTime - Date.now()) / 1000).toString());
149
+ res.writeHead(429, { 'Content-Type': 'application/json' });
150
+ res.end(JSON.stringify({
151
+ error: 'Too Many Requests',
152
+ message: 'Rate limit exceeded. Please try again later.',
153
+ retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000),
154
+ }));
155
+ return false;
156
+ }
157
+
158
+ return true;
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Get current stats
164
+ */
165
+ getStats(): { totalEntries: number; config: { windowMs: number; maxRequests: number } } {
166
+ return {
167
+ totalEntries: this.entries.size,
168
+ config: {
169
+ windowMs: this.windowMs,
170
+ maxRequests: this.maxRequests,
171
+ },
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Reset rate limit for a specific key
177
+ */
178
+ reset(key: string): boolean {
179
+ return this.entries.delete(key);
180
+ }
181
+
182
+ /**
183
+ * Clear all rate limit entries
184
+ */
185
+ clear(): void {
186
+ this.entries.clear();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Create a rate limiter instance with default config
192
+ */
193
+ export function createRateLimiter(config?: RateLimitConfig): RateLimiter {
194
+ return new RateLimiter(config);
195
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * SSE (Server-Sent Events) transport for MCP server
3
+ * Implements the legacy MCP protocol version 2024-11-05
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
8
+ import type { EnvironmentConfig } from '../config.js';
9
+ import type { HaClient } from '../haClient/index.js';
10
+ import type { LocalAIClient } from '../localAI/index.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { createServer } from './common.js';
13
+
14
+ const MESSAGE_PATH = '/messages';
15
+
16
+ interface SSETransportState {
17
+ transport: SSEServerTransport;
18
+ server: ReturnType<typeof createServer>;
19
+ }
20
+
21
+ /**
22
+ * Creates an SSE transport for handling HTTP+SSE connections
23
+ */
24
+ export function createSseTransport(
25
+ client: HaClient,
26
+ config: EnvironmentConfig,
27
+ endpoint: string,
28
+ res: ServerResponse,
29
+ aiClient?: LocalAIClient
30
+ ): SSETransportState {
31
+ const mcpServer = createServer(client, aiClient);
32
+
33
+ const transport = new SSEServerTransport(endpoint, res, {
34
+ allowedOrigins: config.httpAllowedOrigins,
35
+ enableDnsRebindingProtection: true,
36
+ });
37
+
38
+ transport.onerror = (error: Error) => {
39
+ logger.error('SSE transport error', {
40
+ error,
41
+ message: error.message,
42
+ });
43
+ };
44
+
45
+ return { transport, server: mcpServer };
46
+ }
47
+
48
+ /**
49
+ * Handles SSE connection establishment (GET request)
50
+ */
51
+ export async function handleSseConnection(
52
+ client: HaClient,
53
+ config: EnvironmentConfig,
54
+ endpoint: string,
55
+ req: IncomingMessage,
56
+ res: ServerResponse,
57
+ aiClient?: LocalAIClient
58
+ ): Promise<SSETransportState> {
59
+ const originHeader = req.headers.origin;
60
+ const hostHeader = req.headers.host;
61
+
62
+ logger.info('SSE connection request received', {
63
+ method: req.method,
64
+ url: req.url,
65
+ origin: originHeader ?? '(not set)',
66
+ host: hostHeader ?? '(not set)',
67
+ });
68
+
69
+ try {
70
+ const { transport, server } = createSseTransport(client, config, endpoint, res, aiClient);
71
+
72
+ await server.connect(transport);
73
+ await transport.start();
74
+
75
+ logger.info('SSE connection established', {
76
+ sessionId: transport.sessionId,
77
+ });
78
+
79
+ return { transport, server };
80
+ } catch (error) {
81
+ logger.error('Failed to establish SSE connection', {
82
+ error,
83
+ origin: originHeader ?? '(not set)',
84
+ host: hostHeader ?? '(not set)',
85
+ allowedOrigins: config.httpAllowedOrigins,
86
+ });
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Handles SSE message POST requests
93
+ */
94
+ export async function handleSseMessage(
95
+ transport: SSEServerTransport,
96
+ req: IncomingMessage,
97
+ res: ServerResponse,
98
+ parsedBody?: unknown
99
+ ): Promise<void> {
100
+ logger.debug('SSE message received', {
101
+ sessionId: transport.sessionId,
102
+ hasBody: !!parsedBody,
103
+ });
104
+
105
+ await transport.handlePostMessage(req, res, parsedBody);
106
+
107
+ logger.debug('SSE message handled', {
108
+ sessionId: transport.sessionId,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Returns the message endpoint path for SSE transport
114
+ */
115
+ export function getSseMessagePath(): string {
116
+ return MESSAGE_PATH;
117
+ }
118
+
119
+ // Export type
120
+ export type { SSETransportState };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Stdio transport for MCP server
3
+ * Used for Claude Desktop and other stdio-based clients
4
+ */
5
+
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import type { HaClient } from '../haClient/index.js';
8
+ import type { LocalAIClient } from '../localAI/index.js';
9
+ import { logger } from '../utils/logger.js';
10
+ import { createServer } from './common.js';
11
+
12
+ export async function startStdioServer(client: HaClient, aiClient?: LocalAIClient): Promise<void> {
13
+ logger.info('Starting stdio server');
14
+
15
+ const server = createServer(client, aiClient);
16
+ const transport = new StdioServerTransport();
17
+
18
+ logger.info('Connecting stdio server');
19
+ await server.connect(transport);
20
+
21
+ logger.info('Stdio server connected and ready');
22
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Streamable HTTP transport for MCP server
3
+ * Implements the MCP protocol version 2025-03-26 (current/recommended)
4
+ */
5
+
6
+ import { randomUUID } from 'node:crypto';
7
+ import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
+ import type { EnvironmentConfig } from '../config.js';
10
+ import type { HaClient } from '../haClient/index.js';
11
+ import type { LocalAIClient } from '../localAI/index.js';
12
+ import { logger } from '../utils/logger.js';
13
+ import { createServer } from './common.js';
14
+
15
+ interface StreamTransportState {
16
+ transport: StreamableHTTPServerTransport;
17
+ server: ReturnType<typeof createServer>;
18
+ }
19
+
20
+ // Export the type for use in http.ts
21
+ export type { StreamTransportState };
22
+
23
+ /**
24
+ * Creates a Streamable HTTP transport
25
+ * This implements the MCP protocol version 2025-03-26
26
+ */
27
+ export function createStreamTransport(client: HaClient, config: EnvironmentConfig, aiClient?: LocalAIClient): StreamTransportState {
28
+ const mcpServer = createServer(client, aiClient);
29
+
30
+ const enableStatefulSessions = config.stateful;
31
+ const sessionIdGenerator = enableStatefulSessions ? () => randomUUID() : undefined;
32
+
33
+ if (!enableStatefulSessions) {
34
+ logger.info('Starting Streamable HTTP transport in stateless mode; Mcp-Session-Id headers are optional');
35
+ }
36
+
37
+ const transport = new StreamableHTTPServerTransport({
38
+ sessionIdGenerator,
39
+ allowedOrigins: config.httpAllowedOrigins,
40
+ enableDnsRebindingProtection: true,
41
+ onsessioninitialized: (sessionId: string) => {
42
+ logger.info('Session initialized', { sessionId });
43
+ },
44
+ onsessionclosed: (sessionId: string) => {
45
+ logger.info('Session closed', { sessionId });
46
+ },
47
+ });
48
+
49
+ transport.onerror = (error: Error) => {
50
+ logger.error('Streamable HTTP transport error', {
51
+ error,
52
+ message: error.message,
53
+ });
54
+ };
55
+
56
+ return { transport, server: mcpServer };
57
+ }
58
+
59
+ /**
60
+ * Handles incoming Streamable HTTP requests (GET, POST, DELETE)
61
+ * For stateful mode, transports should be stored and reused by the caller
62
+ */
63
+ export async function handleStreamRequest(
64
+ client: HaClient,
65
+ config: EnvironmentConfig,
66
+ req: IncomingMessage,
67
+ res: ServerResponse,
68
+ parsedBody?: unknown,
69
+ existingTransport?: StreamTransportState,
70
+ aiClient?: LocalAIClient
71
+ ): Promise<StreamTransportState | void> {
72
+ const originHeader = req.headers.origin;
73
+ const hostHeader = req.headers.host;
74
+
75
+ logger.info('Streamable HTTP request received', {
76
+ method: req.method,
77
+ url: req.url,
78
+ sessionId: req.headers['mcp-session-id'] ?? undefined,
79
+ origin: originHeader ?? '(not set)',
80
+ host: hostHeader ?? '(not set)',
81
+ });
82
+
83
+ // Reuse existing transport if provided, otherwise create new one
84
+ const state = existingTransport ?? createStreamTransport(client, config, aiClient);
85
+
86
+ if (!existingTransport) {
87
+ await state.server.connect(state.transport);
88
+ }
89
+
90
+ try {
91
+ await state.transport.handleRequest(req, res, parsedBody);
92
+
93
+ logger.debug('Streamable HTTP request handled', {
94
+ method: req.method,
95
+ sessionId: req.headers['mcp-session-id'] ?? undefined,
96
+ });
97
+ } catch (error) {
98
+ logger.error('Failed to handle Streamable HTTP request', {
99
+ error,
100
+ method: req.method,
101
+ url: req.url,
102
+ origin: originHeader ?? '(not set)',
103
+ host: hostHeader ?? '(not set)',
104
+ allowedOrigins: config.httpAllowedOrigins,
105
+ });
106
+ throw error;
107
+ }
108
+
109
+ // Return state for session management if stateful
110
+ if (config.stateful) {
111
+ return state;
112
+ }
113
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Call Service Tool
3
+ * Calls a Home Assistant service
4
+ */
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import {
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+ import type { HaClient } from '../haClient/index.js';
12
+ import type { ServiceCallData } from '../types/index.js';
13
+ import { logger } from '../utils/logger.js';
14
+
15
+ export function registerCallServiceTool(server: Server, client: HaClient): void {
16
+ logger.debug('Registering callService tool');
17
+
18
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
19
+ return {
20
+ tools: [
21
+ {
22
+ name: 'callService',
23
+ description: 'Call a Home Assistant service (e.g., turn on a light, set temperature).',
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ domain: {
28
+ type: 'string',
29
+ description: 'Service domain (e.g., "light", "switch", "climate", "notify")',
30
+ },
31
+ service: {
32
+ type: 'string',
33
+ description: 'Service name (e.g., "turn_on", "turn_off", "set_temperature")',
34
+ },
35
+ service_data: {
36
+ type: 'object',
37
+ description: 'Optional service data (e.g., {"brightness": 255})',
38
+ },
39
+ target: {
40
+ type: 'object',
41
+ description: 'Optional target (entity_id, device_id, or area_id)',
42
+ properties: {
43
+ entity_id: {
44
+ type: ['string', 'array'],
45
+ description: 'Entity ID or array of entity IDs',
46
+ },
47
+ },
48
+ },
49
+ },
50
+ required: ['domain', 'service'],
51
+ },
52
+ },
53
+ ],
54
+ };
55
+ });
56
+
57
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
58
+ if (request.params.name !== 'callService') {
59
+ return {
60
+ content: [
61
+ {
62
+ type: 'text',
63
+ text: JSON.stringify({ error: 'Unknown tool' }),
64
+ },
65
+ ],
66
+ isError: true,
67
+ };
68
+ }
69
+
70
+ try {
71
+ const args = request.params.arguments as unknown as ServiceCallData;
72
+
73
+ if (!args.domain || !args.service) {
74
+ throw new Error('domain and service are required');
75
+ }
76
+
77
+ logger.info('Executing callService tool', {
78
+ domain: args.domain,
79
+ service: args.service,
80
+ });
81
+
82
+ const result = await client.callService(args);
83
+
84
+ return {
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: JSON.stringify({
89
+ success: true,
90
+ context: result.context,
91
+ }, null, 2),
92
+ },
93
+ ],
94
+ };
95
+ } catch (error) {
96
+ logger.error('Failed to call service', {
97
+ error: error instanceof Error ? error.message : String(error),
98
+ });
99
+
100
+ return {
101
+ content: [
102
+ {
103
+ type: 'text',
104
+ text: JSON.stringify({
105
+ error: 'Failed to call service',
106
+ message: error instanceof Error ? error.message : String(error),
107
+ }),
108
+ },
109
+ ],
110
+ isError: true,
111
+ };
112
+ }
113
+ });
114
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Get Entities By Domain Tool
3
+ * Gets all entities for a specific domain
4
+ */
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import {
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+ import type { HaClient } from '../haClient/index.js';
12
+ import { logger } from '../utils/logger.js';
13
+
14
+ export function registerGetEntitiesByDomainTool(server: Server, client: HaClient): void {
15
+ logger.debug('Registering getEntitiesByDomain tool');
16
+
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
18
+ return {
19
+ tools: [
20
+ {
21
+ name: 'getEntitiesByDomain',
22
+ description: 'Get all entities for a specific domain (e.g., all lights, all sensors).',
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ domain: {
27
+ type: 'string',
28
+ description: 'Domain name (e.g., "light", "sensor", "switch", "climate")',
29
+ },
30
+ },
31
+ required: ['domain'],
32
+ },
33
+ },
34
+ ],
35
+ };
36
+ });
37
+
38
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
39
+ if (request.params.name !== 'getEntitiesByDomain') {
40
+ return {
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: JSON.stringify({ error: 'Unknown tool' }),
45
+ },
46
+ ],
47
+ isError: true,
48
+ };
49
+ }
50
+
51
+ try {
52
+ const { domain } = request.params.arguments as { domain: string };
53
+
54
+ if (!domain) {
55
+ throw new Error('domain is required');
56
+ }
57
+
58
+ logger.info('Executing getEntitiesByDomain tool', { domain });
59
+ const entities = await client.getEntitiesByDomain(domain);
60
+
61
+ return {
62
+ content: [
63
+ {
64
+ type: 'text',
65
+ text: JSON.stringify({
66
+ domain,
67
+ count: entities.length,
68
+ entities,
69
+ }, null, 2),
70
+ },
71
+ ],
72
+ };
73
+ } catch (error) {
74
+ logger.error('Failed to get entities by domain', {
75
+ error: error instanceof Error ? error.message : String(error),
76
+ });
77
+
78
+ return {
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: JSON.stringify({
83
+ error: 'Failed to get entities by domain',
84
+ message: error instanceof Error ? error.message : String(error),
85
+ }),
86
+ },
87
+ ],
88
+ isError: true,
89
+ };
90
+ }
91
+ });
92
+ }