@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.4.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 (293) hide show
  1. package/.env.example +1 -1
  2. package/README.md +24 -2
  3. package/dist/omadaClient/accessControl.d.ts +290 -0
  4. package/dist/omadaClient/accessControl.js +509 -0
  5. package/dist/omadaClient/accessControl.js.map +1 -0
  6. package/dist/omadaClient/client.d.ts +6 -1
  7. package/dist/omadaClient/client.js +12 -1
  8. package/dist/omadaClient/client.js.map +1 -1
  9. package/dist/omadaClient/diagnose.d.ts +68 -0
  10. package/dist/omadaClient/diagnose.js +164 -0
  11. package/dist/omadaClient/diagnose.js.map +1 -0
  12. package/dist/omadaClient/index.d.ts +9 -0
  13. package/dist/omadaClient/index.js +19 -0
  14. package/dist/omadaClient/index.js.map +1 -1
  15. package/dist/omadaClient/macGroups.d.ts +58 -0
  16. package/dist/omadaClient/macGroups.js +110 -0
  17. package/dist/omadaClient/macGroups.js.map +1 -0
  18. package/dist/omadaClient/serialize.d.ts +13 -0
  19. package/dist/omadaClient/serialize.js +29 -0
  20. package/dist/omadaClient/serialize.js.map +1 -0
  21. package/dist/omadaClient/timeRange.d.ts +41 -0
  22. package/dist/omadaClient/timeRange.js +109 -0
  23. package/dist/omadaClient/timeRange.js.map +1 -0
  24. package/dist/tools/infra/actions.d.ts +1 -1
  25. package/dist/tools/infra/actions.js +1 -1
  26. package/dist/tools/omada/accessControl.d.ts +151 -0
  27. package/dist/tools/omada/accessControl.js +158 -0
  28. package/dist/tools/omada/accessControl.js.map +1 -0
  29. package/dist/tools/omada/index.js +9 -0
  30. package/dist/tools/omada/index.js.map +1 -1
  31. package/dist/tools/omada/macGroups.d.ts +19 -0
  32. package/dist/tools/omada/macGroups.js +77 -0
  33. package/dist/tools/omada/macGroups.js.map +1 -0
  34. package/dist/tools/omada/namespace.js +16 -0
  35. package/dist/tools/omada/namespace.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +5 -1
  39. package/.codex/review-rules.md +0 -16
  40. package/.dockerignore +0 -8
  41. package/.gitattributes +0 -30
  42. package/.github/workflows/ci.yml +0 -193
  43. package/AGENTS.md +0 -389
  44. package/Dockerfile +0 -50
  45. package/docker-compose.yml +0 -23
  46. package/docs/CHANGELOG.md +0 -341
  47. package/docs/COVERAGE.md +0 -219
  48. package/docs/QUICK_START.md +0 -240
  49. package/eslint.config.js +0 -33
  50. package/n8n/ai-agent-safety-monitor.json +0 -355
  51. package/openapi.json +0 -1259
  52. package/scripts/check-version-sync.mjs +0 -69
  53. package/scripts/generate-jwt.ts +0 -121
  54. package/src/config.ts +0 -440
  55. package/src/haClient/automations.ts +0 -261
  56. package/src/haClient/calendars.ts +0 -94
  57. package/src/haClient/config.ts +0 -52
  58. package/src/haClient/devices.ts +0 -653
  59. package/src/haClient/entities.ts +0 -128
  60. package/src/haClient/events.ts +0 -689
  61. package/src/haClient/history.ts +0 -73
  62. package/src/haClient/index.ts +0 -336
  63. package/src/haClient/request.ts +0 -142
  64. package/src/haClient/services.ts +0 -45
  65. package/src/haClient/states.ts +0 -60
  66. package/src/haClient/updates.ts +0 -58
  67. package/src/haClient/wsCommand.ts +0 -44
  68. package/src/index.ts +0 -164
  69. package/src/localAI/index.ts +0 -88
  70. package/src/localAI/prompts.ts +0 -50
  71. package/src/localAI/providers/base.ts +0 -120
  72. package/src/localAI/providers/ollama.ts +0 -78
  73. package/src/localAI/providers/openai.ts +0 -86
  74. package/src/localAI/types.ts +0 -67
  75. package/src/omadaClient/auth.ts +0 -178
  76. package/src/omadaClient/client.ts +0 -337
  77. package/src/omadaClient/device.ts +0 -118
  78. package/src/omadaClient/index.ts +0 -310
  79. package/src/omadaClient/network.ts +0 -284
  80. package/src/omadaClient/request.ts +0 -365
  81. package/src/omadaClient/security.ts +0 -51
  82. package/src/omadaClient/site.ts +0 -344
  83. package/src/ownerQuestions/service.ts +0 -536
  84. package/src/permissions/index.ts +0 -143
  85. package/src/resources/index.ts +0 -213
  86. package/src/server/auth.ts +0 -175
  87. package/src/server/chatFace.ts +0 -789
  88. package/src/server/chatSlice.ts +0 -57
  89. package/src/server/common.ts +0 -170
  90. package/src/server/eventSubscription.ts +0 -343
  91. package/src/server/http.ts +0 -626
  92. package/src/server/instructions.ts +0 -97
  93. package/src/server/rateLimiter.ts +0 -227
  94. package/src/server/stdio.ts +0 -41
  95. package/src/server/stream.ts +0 -205
  96. package/src/tools/ai/analyzeSensors.ts +0 -33
  97. package/src/tools/ai/index.ts +0 -20
  98. package/src/tools/common.ts +0 -442
  99. package/src/tools/homeassistant/askOwner.ts +0 -133
  100. package/src/tools/homeassistant/automationControls.ts +0 -66
  101. package/src/tools/homeassistant/calendar.ts +0 -97
  102. package/src/tools/homeassistant/callService.ts +0 -25
  103. package/src/tools/homeassistant/controlClimate.ts +0 -68
  104. package/src/tools/homeassistant/controlCover.ts +0 -48
  105. package/src/tools/homeassistant/controlFan.ts +0 -44
  106. package/src/tools/homeassistant/controlLight.ts +0 -38
  107. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  108. package/src/tools/homeassistant/createAutomation.ts +0 -35
  109. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  110. package/src/tools/homeassistant/entityAction.ts +0 -35
  111. package/src/tools/homeassistant/entityGuard.ts +0 -28
  112. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  113. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  114. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  115. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  116. package/src/tools/homeassistant/getHistory.ts +0 -24
  117. package/src/tools/homeassistant/getState.ts +0 -24
  118. package/src/tools/homeassistant/getStates.ts +0 -46
  119. package/src/tools/homeassistant/getVersion.ts +0 -21
  120. package/src/tools/homeassistant/index.ts +0 -121
  121. package/src/tools/homeassistant/listAutomations.ts +0 -35
  122. package/src/tools/homeassistant/listEntities.ts +0 -44
  123. package/src/tools/homeassistant/listPersons.ts +0 -46
  124. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  125. package/src/tools/homeassistant/searchEntities.ts +0 -58
  126. package/src/tools/homeassistant/sendNotification.ts +0 -179
  127. package/src/tools/homeassistant/systemTools.ts +0 -52
  128. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  129. package/src/tools/index.ts +0 -78
  130. package/src/tools/infra/actions.ts +0 -95
  131. package/src/tools/infra/index.ts +0 -138
  132. package/src/tools/omada/blockClient.ts +0 -28
  133. package/src/tools/omada/cyclePoePort.ts +0 -32
  134. package/src/tools/omada/deleteClient.ts +0 -27
  135. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  136. package/src/tools/omada/getClient.ts +0 -14
  137. package/src/tools/omada/getDevice.ts +0 -14
  138. package/src/tools/omada/getFirewallSetting.ts +0 -14
  139. package/src/tools/omada/getInternetInfo.ts +0 -14
  140. package/src/tools/omada/getLanNetworkList.ts +0 -14
  141. package/src/tools/omada/getLanProfileList.ts +0 -14
  142. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  143. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  144. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  145. package/src/tools/omada/getSsidDetail.ts +0 -22
  146. package/src/tools/omada/getSsidList.ts +0 -21
  147. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  148. package/src/tools/omada/getThreatList.ts +0 -42
  149. package/src/tools/omada/getWlanGroupList.ts +0 -14
  150. package/src/tools/omada/graph.ts +0 -187
  151. package/src/tools/omada/index.ts +0 -153
  152. package/src/tools/omada/listBlockedClients.ts +0 -17
  153. package/src/tools/omada/listClients.ts +0 -14
  154. package/src/tools/omada/listClientsActivity.ts +0 -25
  155. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  156. package/src/tools/omada/listDevices.ts +0 -14
  157. package/src/tools/omada/listDevicesStats.ts +0 -41
  158. package/src/tools/omada/listMostActiveClients.ts +0 -14
  159. package/src/tools/omada/listSites.ts +0 -18
  160. package/src/tools/omada/namespace.ts +0 -1164
  161. package/src/tools/omada/searchDevices.ts +0 -20
  162. package/src/tools/omada/setClientRateLimit.ts +0 -26
  163. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  164. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  165. package/src/tools/omada/setSsidEnabled.ts +0 -38
  166. package/src/tools/omada/unblockClient.ts +0 -26
  167. package/src/tools/types.ts +0 -11
  168. package/src/tools/utils/pagination-schema.ts +0 -27
  169. package/src/types/ai/index.ts +0 -11
  170. package/src/types/ai/types.ts +0 -67
  171. package/src/types/homeassistant/automations.ts +0 -43
  172. package/src/types/homeassistant/calendars.ts +0 -26
  173. package/src/types/homeassistant/config.ts +0 -14
  174. package/src/types/homeassistant/entities.ts +0 -14
  175. package/src/types/homeassistant/errors.ts +0 -33
  176. package/src/types/homeassistant/haConfig.ts +0 -7
  177. package/src/types/homeassistant/history.ts +0 -13
  178. package/src/types/homeassistant/index.ts +0 -46
  179. package/src/types/homeassistant/request.ts +0 -6
  180. package/src/types/homeassistant/serviceCall.ts +0 -18
  181. package/src/types/homeassistant/states.ts +0 -58
  182. package/src/types/homeassistant/updates.ts +0 -28
  183. package/src/types/index.ts +0 -12
  184. package/src/types/omada/activeClientInfo.ts +0 -17
  185. package/src/types/omada/clientActivity.ts +0 -32
  186. package/src/types/omada/clientBlockStatus.ts +0 -11
  187. package/src/types/omada/clientPastConnection.ts +0 -82
  188. package/src/types/omada/index.ts +0 -14
  189. package/src/types/omada/omadaApiResponse.ts +0 -5
  190. package/src/types/omada/omadaClientInfo.ts +0 -11
  191. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  192. package/src/types/omada/omadaDeviceStats.ts +0 -31
  193. package/src/types/omada/omadaSiteSummary.ts +0 -5
  194. package/src/types/omada/oswStackDetail.ts +0 -11
  195. package/src/types/omada/paginatedResult.ts +0 -6
  196. package/src/types/omada/rateLimitProfile.ts +0 -58
  197. package/src/types/omada/threatInfo.ts +0 -38
  198. package/src/types/omada/tokenResult.ts +0 -6
  199. package/src/types.ts +0 -2
  200. package/src/utils/config-validations.ts +0 -68
  201. package/src/utils/jwt.ts +0 -112
  202. package/src/utils/logger.ts +0 -121
  203. package/src/utils/plainText.ts +0 -48
  204. package/src/utils/redact.ts +0 -59
  205. package/src/utils/sanitizeError.ts +0 -18
  206. package/src/utils/tlsDispatcher.ts +0 -69
  207. package/src/version.ts +0 -5
  208. package/tests/bdd/automation-creation.spec.ts +0 -214
  209. package/tests/bdd/automation-management.spec.ts +0 -267
  210. package/tests/bdd/calendar.spec.ts +0 -190
  211. package/tests/bdd/configuration.spec.ts +0 -283
  212. package/tests/bdd/device-control.spec.ts +0 -344
  213. package/tests/bdd/entity-listing.spec.ts +0 -208
  214. package/tests/bdd/entity-search.spec.ts +0 -183
  215. package/tests/bdd/history-retrieval.spec.ts +0 -195
  216. package/tests/bdd/notifications.spec.ts +0 -238
  217. package/tests/bdd/service-calls.spec.ts +0 -144
  218. package/tests/bdd/state-retrieval.spec.ts +0 -204
  219. package/tests/bench/hotpaths.bench.ts +0 -68
  220. package/tests/config.test.ts +0 -355
  221. package/tests/features/automation-creation.feature +0 -36
  222. package/tests/features/automation-management.feature +0 -54
  223. package/tests/features/calendar.feature +0 -37
  224. package/tests/features/configuration.feature +0 -70
  225. package/tests/features/device-control.feature +0 -100
  226. package/tests/features/entity-listing.feature +0 -51
  227. package/tests/features/entity-search.feature +0 -44
  228. package/tests/features/history-retrieval.feature +0 -38
  229. package/tests/features/notifications.feature +0 -37
  230. package/tests/features/service-calls.feature +0 -35
  231. package/tests/features/state-retrieval.feature +0 -37
  232. package/tests/fixtures/openapi-default-slice.json +0 -290
  233. package/tests/haClient/automationTrace.test.ts +0 -59
  234. package/tests/haClient/automations.test.ts +0 -149
  235. package/tests/haClient/config.test.ts +0 -117
  236. package/tests/haClient/entities.test.ts +0 -188
  237. package/tests/haClient/events.test.ts +0 -455
  238. package/tests/haClient/eventsTls.test.ts +0 -52
  239. package/tests/haClient/history.test.ts +0 -228
  240. package/tests/haClient/index.test.ts +0 -243
  241. package/tests/haClient/request.test.ts +0 -217
  242. package/tests/haClient/services.test.ts +0 -120
  243. package/tests/haClient/states.test.ts +0 -119
  244. package/tests/haClient/updates.test.ts +0 -193
  245. package/tests/index.test.ts +0 -160
  246. package/tests/integration/homeassistant.test.ts +0 -144
  247. package/tests/integration/omada.test.ts +0 -167
  248. package/tests/integration/server.test.ts +0 -151
  249. package/tests/localAI/index.test.ts +0 -177
  250. package/tests/localAI/ollama.test.ts +0 -187
  251. package/tests/localAI/openai.test.ts +0 -257
  252. package/tests/omadaClient/actions.test.ts +0 -161
  253. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  254. package/tests/omadaClient/ntp.test.ts +0 -381
  255. package/tests/omadaClient/site.test.ts +0 -37
  256. package/tests/ownerQuestions/service.test.ts +0 -499
  257. package/tests/ownerQuestions/tools.test.ts +0 -186
  258. package/tests/permissions/index.test.ts +0 -217
  259. package/tests/resources/index.test.ts +0 -288
  260. package/tests/scripts/generateJwt.test.ts +0 -44
  261. package/tests/security/accessControl.test.ts +0 -223
  262. package/tests/security/authentication.test.ts +0 -117
  263. package/tests/security/configuration.test.ts +0 -170
  264. package/tests/security/cryptoTransport.test.ts +0 -172
  265. package/tests/security/dependencies.test.ts +0 -152
  266. package/tests/security/injection.test.ts +0 -117
  267. package/tests/security/insecureDesign.test.ts +0 -121
  268. package/tests/security/logging.test.ts +0 -131
  269. package/tests/security/resourceConsumption.test.ts +0 -142
  270. package/tests/server/auth.test.ts +0 -249
  271. package/tests/server/chatFace.test.ts +0 -320
  272. package/tests/server/common.test.ts +0 -84
  273. package/tests/server/http.test.ts +0 -561
  274. package/tests/server/instructions.test.ts +0 -152
  275. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  276. package/tests/server/stdio.test.ts +0 -99
  277. package/tests/server/stream.test.ts +0 -285
  278. package/tests/server/toolVisibility.http.test.ts +0 -111
  279. package/tests/server/toolVisibility.test.ts +0 -129
  280. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  281. package/tests/tools/common.test.ts +0 -123
  282. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  283. package/tests/tools/infra/invokeAction.test.ts +0 -280
  284. package/tests/tools/omada/graph.test.ts +0 -385
  285. package/tests/tools/omada/handlers.test.ts +0 -897
  286. package/tests/tools/omada/namespace.test.ts +0 -483
  287. package/tests/tools/registry.test.ts +0 -168
  288. package/tests/utils/config-validations.test.ts +0 -352
  289. package/tests/utils/jwt.test.ts +0 -115
  290. package/tests/utils/logger.test.ts +0 -327
  291. package/tests/utils/redact.test.ts +0 -105
  292. package/tsconfig.json +0 -24
  293. package/vitest.config.ts +0 -38
@@ -1,626 +0,0 @@
1
- /**
2
- * HTTP server for MCP with Streamable HTTP transport
3
- * Includes health check, CORS support, rate limiting, and real-time event subscriptions
4
- */
5
-
6
- import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
7
- import type { EnvironmentConfig } from '../config.js';
8
- import type { HaClient } from '../haClient/index.js';
9
- import { EventSubscriber } from '../haClient/events.js';
10
- import type { LocalAIClient } from '../localAI/index.js';
11
- import type { OmadaClient } from '../omadaClient/index.js';
12
- import { logger } from '../utils/logger.js';
13
- import { handleStreamRequest, type StreamTransportState, type StreamTransportOptions } from './stream.js';
14
- import { handleEventSubscription } from './eventSubscription.js';
15
- import { RateLimiter } from './rateLimiter.js';
16
- import { createAuthMiddleware } from './auth.js';
17
- import { sanitizeError } from '../utils/sanitizeError.js';
18
- import { OwnerQuestionService } from '../ownerQuestions/service.js';
19
- import {
20
- LEGACY_BINDINGS,
21
- resolveChatBindings,
22
- buildOpenApiSpec,
23
- dispatchChatRequest,
24
- requiredPermissionFor,
25
- type ChatBinding,
26
- } from './chatFace.js';
27
-
28
- // Session storage for stateful mode
29
- const sessions = new Map<string, StreamTransportState>();
30
-
31
- /**
32
- * Wrap response to add Server-Timing header with request duration
33
- * The timing is calculated and header set just before res.end() is called
34
- */
35
- function wrapResponseWithTiming(res: ServerResponse): { res: ServerResponse; startTime: number } {
36
- const startTime = performance.now();
37
- const originalEnd = res.end.bind(res);
38
-
39
- // Override res.end to set timing header before sending
40
- res.end = function (
41
- chunk?: unknown,
42
- encodingOrCallback?: BufferEncoding | (() => void),
43
- callback?: () => void
44
- ): ServerResponse {
45
- // Set Server-Timing header if headers haven't been sent
46
- if (!res.headersSent) {
47
- const durationMs = Math.round(performance.now() - startTime);
48
- res.setHeader('Server-Timing', `total;dur=${durationMs}`);
49
- }
50
-
51
- // Call original end with proper overload handling
52
- if (typeof encodingOrCallback === 'function') {
53
- return originalEnd(chunk, encodingOrCallback);
54
- }
55
- if (encodingOrCallback !== undefined) {
56
- return originalEnd(chunk, encodingOrCallback, callback);
57
- }
58
- return originalEnd(chunk, callback);
59
- } as typeof res.end;
60
-
61
- return { res, startTime };
62
- }
63
-
64
- /**
65
- * Maximum accepted request body size, in bytes (M5 / OWASP API4:2023).
66
- * 1 MB is comfortably above any legitimate MCP/JSON-RPC or REST payload.
67
- */
68
- export const MAX_BODY_BYTES = 1024 * 1024;
69
-
70
- /** Error carrying an HTTP status for the request handler to surface. */
71
- interface HttpError extends Error {
72
- statusCode?: number;
73
- }
74
-
75
- /**
76
- * Parse a JSON request body, enforcing a hard size cap (M5).
77
- *
78
- * If the streamed body exceeds `maxBytes` the promise rejects with a 413-tagged
79
- * error and the socket is destroyed, so an attacker cannot exhaust memory by
80
- * streaming an unbounded payload. Buffering stops the moment the cap is crossed.
81
- */
82
- export function parseBody(req: IncomingMessage, maxBytes: number = MAX_BODY_BYTES): Promise<unknown> {
83
- return new Promise((resolve, reject) => {
84
- const chunks: Buffer[] = [];
85
- let size = 0;
86
- let aborted = false;
87
-
88
- req.on('data', (chunk: Buffer | string) => {
89
- if (aborted) {
90
- return;
91
- }
92
- const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
93
- size += buf.length;
94
- if (size > maxBytes) {
95
- aborted = true;
96
- const err: HttpError = new Error('Request body too large');
97
- err.statusCode = 413;
98
- reject(err);
99
- req.destroy();
100
- return;
101
- }
102
- chunks.push(buf);
103
- });
104
-
105
- req.on('end', () => {
106
- if (aborted) {
107
- return;
108
- }
109
- if (chunks.length === 0) {
110
- resolve(undefined);
111
- return;
112
- }
113
- try {
114
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
115
- } catch (error) {
116
- // A malformed body is a client mistake, not a server fault: tag it 400 so
117
- // the request handler surfaces "400 Bad Request" instead of a generic 500.
118
- const err: HttpError = new Error(`Invalid JSON: ${(error as Error).message}`);
119
- err.statusCode = 400;
120
- reject(err);
121
- }
122
- });
123
-
124
- req.on('error', reject);
125
- });
126
- }
127
-
128
- /** Extract a numeric HTTP status from a thrown value, if present. */
129
- function errorStatusCode(error: unknown): number | undefined {
130
- const code = (error as HttpError | undefined)?.statusCode;
131
- return typeof code === 'number' ? code : undefined;
132
- }
133
-
134
- /**
135
- * Apply baseline security response headers (L4 / OWASP A05:2021).
136
- *
137
- * Cheap, static defenses applied to every response: block MIME sniffing,
138
- * forbid framing (clickjacking), and avoid leaking full URLs in the Referer.
139
- */
140
- export function applySecurityHeaders(res: ServerResponse): void {
141
- res.setHeader('X-Content-Type-Options', 'nosniff');
142
- res.setHeader('X-Frame-Options', 'DENY');
143
- res.setHeader('Referrer-Policy', 'no-referrer');
144
- }
145
-
146
- /**
147
- * Send JSON response with proper Content-Length header
148
- */
149
- function sendJson(res: ServerResponse, statusCode: number, data: unknown): void {
150
- const payload = JSON.stringify(data);
151
- res.statusCode = statusCode;
152
- res.setHeader('Content-Type', 'application/json');
153
- res.setHeader('Content-Length', Buffer.byteLength(payload).toString());
154
- res.end(payload);
155
- }
156
-
157
- /**
158
- * Handle health check endpoint (M7 / OWASP A09:2021).
159
- *
160
- * `/health` is unauthenticated (it is on the auth skip-list so container/orchestrator
161
- * probes can reach it). It therefore returns liveness ONLY — never version, auth
162
- * method, AI provider URLs, client counts, or any other internal configuration that
163
- * would aid an unauthenticated attacker fingerprinting the deployment. Operators who
164
- * need that detail can query the authenticated MCP tools (e.g. `getVersion`).
165
- */
166
- export function handleHealthCheck(res: ServerResponse): void {
167
- sendJson(res, 200, { status: 'healthy' });
168
- }
169
-
170
- /**
171
- * Add CORS headers for REST API endpoints (always enabled for Open WebUI compatibility)
172
- */
173
- export function addRestApiCors(req: IncomingMessage, res: ServerResponse): void {
174
- const origin = req.headers.origin;
175
- res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
176
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
177
- // `x-session-id` is sent by Open WebUI on every tool call. A browser blocks the
178
- // request entirely (CORS preflight failure -> "NetworkError when attempting to
179
- // fetch resource") if a requested header is not listed here, so it must be allowed.
180
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-session-id');
181
- res.setHeader('Access-Control-Max-Age', '86400');
182
- }
183
-
184
- /**
185
- * Whether a request path is served by the browser-facing REST surface
186
- * (`/openapi.json`, the `/api/*` bridge, or the SSE events stream) and therefore
187
- * must always carry CORS headers — INCLUDING on responses produced before the
188
- * route handler runs (rate-limit 429, auth 401). If those rejections go back
189
- * without `Access-Control-Allow-Origin`, the browser discards the response and
190
- * surfaces it as an opaque "NetworkError when attempting to fetch resource",
191
- * masking the real status (this is why an Open WebUI 401 looked like a network
192
- * failure). `url` is the raw request URL (query string included); `urlPath` is
193
- * the path-only form used to match the configurable SSE events path.
194
- */
195
- export function isRestApiCorsPath(url: string, urlPath: string, sseEventsPath: string): boolean {
196
- return url === '/openapi.json' || url.startsWith('/api/') || urlPath === sseEventsPath;
197
- }
198
-
199
- /**
200
- * Resolve the permission a REST `/api/*` route requires (legacy surface).
201
- *
202
- * v1.7: derived from the chat-face binding registry — each binding carries the
203
- * `Permission.*` bit of its MCP-tool twin so the REST bridge cannot be used to
204
- * bypass RBAC (finding H1 / OWASP A01:2021, BFLA). Unknown routes return
205
- * `null` — the handler falls through to a 404, which needs no permission.
206
- */
207
- export function requiredRestPermission(method: string, pathname: string): number | null {
208
- return requiredPermissionFor(LEGACY_BINDINGS, method, pathname);
209
- }
210
-
211
- /**
212
- * Handle REST API requests for Open WebUI compatibility (legacy entry point).
213
- *
214
- * v1.7: thin delegate to the chat-face dispatcher over the legacy bindings —
215
- * kept so existing callers/tests exercise the exact same code path the server
216
- * uses. New code should call `dispatchChatRequest` with resolved bindings.
217
- */
218
- export async function handleRestApi(
219
- req: IncomingMessage,
220
- res: ServerResponse,
221
- url: string,
222
- client: HaClient
223
- ): Promise<void> {
224
- // Always add CORS for REST API
225
- addRestApiCors(req, res);
226
- await dispatchChatRequest(req, res, url, {
227
- bindings: LEGACY_BINDINGS,
228
- deps: { haClient: client },
229
- parseBody,
230
- sendJson,
231
- });
232
- }
233
-
234
- /**
235
- * Stateless mode has no server→client push stream: every request gets a fresh
236
- * transport, so the SDK's standalone SSE GET would open a stream that never
237
- * emits a byte and never ends. That eternal silent response pins the
238
- * (Cloudflare→origin) keep-alive socket, and HTTP/1.1 serial semantics then
239
- * stall every later request queued on that socket — mcp-remote's own probe
240
- * GET poisoned the connection its initialize POST was later queued on,
241
- * surfacing as "Request timed out". Per the MCP spec, a server that does not
242
- * offer a GET event stream MUST return 405 Method Not Allowed.
243
- */
244
- export function isStatelessMcpGet(stateful: boolean, method: string | undefined): boolean {
245
- return !stateful && method === 'GET';
246
- }
247
-
248
- /**
249
- * Resolve the public URL scheme, honoring `X-Forwarded-Proto` (set by Cloudflare /
250
- * reverse proxies that TLS-terminate upstream). The header is validated against an
251
- * allowlist so a spoofed value can't inject an arbitrary scheme; anything other than
252
- * a literal `https` falls back to `http`. Comma-separated values (proxy chains) take
253
- * the first hop.
254
- */
255
- export function resolveForwardedProto(headerValue: string | string[] | undefined): 'http' | 'https' {
256
- const first = Array.isArray(headerValue) ? headerValue[0] : headerValue;
257
- const proto = String(first ?? '')
258
- .split(',')[0]
259
- ?.trim()
260
- .toLowerCase();
261
- return proto === 'https' ? 'https' : 'http';
262
- }
263
-
264
- /**
265
- * Generate OpenAPI spec for Open WebUI compatibility (legacy entry point).
266
- *
267
- * v1.7: generated from the chat-face binding registry. With no MCP_CHAT_TOOLS
268
- * slice this renders the legacy 8-tool spec byte-for-byte (golden-file test:
269
- * tests/fixtures/openapi-default-slice.json).
270
- */
271
- export function getOpenApiSpec(baseUrl: string): object {
272
- return buildOpenApiSpec(baseUrl, LEGACY_BINDINGS);
273
- }
274
-
275
- /**
276
- * Handle CORS preflight
277
- */
278
- function handleCors(req: IncomingMessage, res: ServerResponse, config: EnvironmentConfig): void {
279
- if (!config.httpAllowCors) {
280
- return;
281
- }
282
-
283
- const origin = req.headers.origin;
284
- const allowedOrigins = config.httpAllowedOrigins ?? [];
285
-
286
- // If origins list is empty, allow all (wildcard was used)
287
- if (allowedOrigins.length === 0 || (origin && allowedOrigins.includes(origin))) {
288
- res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
289
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
290
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
291
- res.setHeader('Access-Control-Max-Age', '86400');
292
- }
293
- }
294
-
295
- /**
296
- * Start HTTP server
297
- */
298
- export interface HttpServerOptions {
299
- haClient?: HaClient;
300
- omadaClient?: OmadaClient;
301
- config: EnvironmentConfig;
302
- aiClient?: LocalAIClient;
303
- }
304
-
305
- export async function startHttpServer(options: HttpServerOptions): Promise<void> {
306
- const { haClient: client, omadaClient, config, aiClient } = options;
307
-
308
- // v1.7: resolve the ACTIVE chat-face bindings once at startup — the operator's
309
- // MCP_CHAT_TOOLS slice ∩ code-side chat-eligible bindings (unset = legacy 8).
310
- const chatBindings: ChatBinding[] = resolveChatBindings(config.chatTools, {
311
- haClient: client,
312
- omadaClient,
313
- });
314
- logger.info('Chat face resolved', {
315
- tools: chatBindings.map((b) => b.operationId),
316
- sliceConfigured: config.chatTools !== undefined,
317
- });
318
-
319
- const port = config.httpPort ?? 3000;
320
- const bindAddr = config.httpBindAddr ?? '127.0.0.1';
321
- const mcpPath = config.httpPath ?? '/mcp';
322
- const healthPath = config.httpHealthcheckPath ?? '/health';
323
- const sseEventsPath = config.sseEventsPath ?? '/subscribe_events';
324
-
325
- // Initialize rate limiter if enabled
326
- const rateLimiter = config.rateLimitEnabled
327
- ? new RateLimiter({
328
- windowMs: config.rateLimitWindowMs,
329
- maxRequests: config.rateLimitMaxRequests,
330
- skipPaths: [healthPath, '/openapi.json'],
331
- // M4: only trust forwarding headers from configured proxies.
332
- trustedProxies: config.rateLimitTrustedProxies,
333
- })
334
- : null;
335
-
336
- // Initialize auth middleware
337
- const authMiddleware = createAuthMiddleware({
338
- method: config.authMethod,
339
- secret: config.authSecret,
340
- permissions: config.permissions,
341
- skipPaths: [healthPath, '/openapi.json'],
342
- requireExp: config.authRequireExp,
343
- issuer: config.authIssuer,
344
- audience: config.authAudience,
345
- });
346
-
347
- // Initialize the HA event subscriber when SSE events or owner questions need it
348
- let eventSubscriber: EventSubscriber | null = null;
349
- if ((config.sseEventsEnabled || config.ownerQuestionsEnabled) && config.baseUrl && config.token) {
350
- eventSubscriber = new EventSubscriber({
351
- baseUrl: config.baseUrl,
352
- token: config.token,
353
- strictSsl: config.strictSsl,
354
- });
355
-
356
- // Connect to Home Assistant WebSocket. On failure the subscriber keeps
357
- // retrying in the background; it is kept (not dropped) so events and owner
358
- // questions start working as soon as Home Assistant is reachable.
359
- try {
360
- await eventSubscriber.connect();
361
- logger.info('Event subscriber connected to Home Assistant');
362
- } catch (error) {
363
- logger.warn('Home Assistant WebSocket not reachable yet; retrying in the background', {
364
- error: error instanceof Error ? error.message : String(error),
365
- });
366
- }
367
- }
368
-
369
- // Owner questions (askOwner & co.) listen for notification taps on the subscriber.
370
- let ownerQuestions: OwnerQuestionService | undefined;
371
- if (config.ownerQuestionsEnabled && eventSubscriber && client) {
372
- const subscriber = eventSubscriber;
373
- const service = new OwnerQuestionService({
374
- notify: (target, serviceData) => client.callService({ domain: 'notify', service: target, service_data: serviceData }),
375
- subscribe: (eventType, callback, onRejected) => subscriber.subscribeEventType(eventType, callback, onRejected),
376
- secret: config.authSecret,
377
- allowedUserIds: config.ownerQuestionsHaUsers,
378
- });
379
- try {
380
- await service.start();
381
- ownerQuestions = service;
382
- } catch (error) {
383
- logger.warn('Owner questions unavailable: could not subscribe to notification taps', {
384
- error: error instanceof Error ? error.message : String(error),
385
- });
386
- }
387
- }
388
-
389
- logger.info('Starting HTTP server', {
390
- port,
391
- bindAddr,
392
- transport: 'stream',
393
- mcpPath,
394
- healthPath,
395
- eventsPath: config.sseEventsEnabled ? sseEventsPath : undefined,
396
- stateful: config.stateful,
397
- aiEnabled: !!aiClient,
398
- rateLimitEnabled: config.rateLimitEnabled,
399
- eventsEnabled: config.sseEventsEnabled && !!eventSubscriber,
400
- });
401
-
402
- const server = createHttpServer(async (req: IncomingMessage, originalRes: ServerResponse) => {
403
- // Wrap response to add Server-Timing header
404
- const { res } = wrapResponseWithTiming(originalRes);
405
-
406
- // L4: baseline security headers on every response.
407
- applySecurityHeaders(res);
408
-
409
- const url = req.url ?? '/';
410
- const urlPath = url.split('?')[0];
411
-
412
- // Handle OPTIONS (CORS preflight) - add CORS headers for REST API endpoints
413
- if (req.method === 'OPTIONS') {
414
- if (isRestApiCorsPath(url, urlPath, sseEventsPath)) {
415
- addRestApiCors(req, res);
416
- } else {
417
- handleCors(req, res, config);
418
- }
419
- res.writeHead(204);
420
- res.end();
421
- return;
422
- }
423
-
424
- // Apply CORS headers for the browser-facing REST surface BEFORE rate limiting
425
- // and auth, so a 429 or 401 rejection still carries `Access-Control-Allow-Origin`.
426
- // Otherwise the browser masks the real status as an opaque "NetworkError when
427
- // attempting to fetch resource" (Open WebUI auth failures looked like this).
428
- if (isRestApiCorsPath(url, urlPath, sseEventsPath)) {
429
- addRestApiCors(req, res);
430
- }
431
-
432
- // Apply rate limiting (skip health checks and certain paths)
433
- if (rateLimiter) {
434
- const allowed = rateLimiter.middleware()(req, res);
435
- if (!allowed) {
436
- return; // Response already sent by rate limiter
437
- }
438
- }
439
-
440
- // Apply authentication (skip health checks and openapi.json)
441
- if (!authMiddleware(req, res)) {
442
- return; // Response already sent by auth middleware
443
- }
444
-
445
- // Add CORS headers for MCP endpoints
446
- handleCors(req, res, config);
447
-
448
- try {
449
- // Health check endpoint
450
- if (config.httpEnableHealthcheck && url === healthPath) {
451
- handleHealthCheck(res);
452
- return;
453
- }
454
-
455
- // SSE Event Subscription endpoint
456
- if (config.sseEventsEnabled && eventSubscriber && urlPath === sseEventsPath) {
457
- if (req.method === 'GET') {
458
- addRestApiCors(req, res);
459
- await handleEventSubscription(req, res, eventSubscriber);
460
- return;
461
- }
462
- sendJson(res, 405, { error: 'Method not allowed. Use GET for SSE subscription.' });
463
- return;
464
- }
465
-
466
- // OpenAPI spec endpoint for Open WebUI compatibility
467
- if (req.method === 'GET' && url === '/openapi.json') {
468
- addRestApiCors(req, res);
469
- // Honor X-Forwarded-Proto (set by Cloudflare / reverse proxies) so the advertised
470
- // server URL is https when the public endpoint is TLS-terminated upstream. Without
471
- // this, an https Open WebUI page is handed an http:// server URL and the browser
472
- // blocks tool calls as mixed content ("NetworkError when attempting to fetch resource").
473
- const scheme = resolveForwardedProto(req.headers['x-forwarded-proto']);
474
- const baseUrl = `${scheme}://${req.headers.host ?? `${bindAddr}:${port}`}`;
475
- sendJson(res, 200, buildOpenApiSpec(baseUrl, chatBindings));
476
- return;
477
- }
478
-
479
- // REST API endpoints for Open WebUI compatibility (chat face). Per-binding
480
- // client requirements (HA vs Omada) are enforced inside the dispatcher.
481
- if (url.startsWith('/api/')) {
482
- await dispatchChatRequest(req, res, url, {
483
- bindings: chatBindings,
484
- deps: { haClient: client, omadaClient },
485
- parseBody,
486
- sendJson,
487
- });
488
- return;
489
- }
490
-
491
- // Streamable HTTP Transport (MCP endpoint)
492
- if (urlPath === mcpPath) {
493
- // See isStatelessMcpGet: an SSE GET in stateless mode would hang forever
494
- // and poison the keep-alive socket for every request queued behind it.
495
- if (isStatelessMcpGet(config.stateful, req.method)) {
496
- res.setHeader('Allow', 'POST, DELETE');
497
- sendJson(res, 405, {
498
- error: 'Method Not Allowed',
499
- message: 'This server runs in stateless mode: no standalone SSE stream. Send JSON-RPC via POST.',
500
- });
501
- return;
502
- }
503
-
504
- const sessionId = req.headers['mcp-session-id'] as string | undefined;
505
- const existingState = sessionId ? sessions.get(sessionId) : undefined;
506
-
507
- const body = req.method !== 'GET' ? await parseBody(req) : undefined;
508
- const streamOptions: StreamTransportOptions = { haClient: client, omadaClient, aiClient, config, ownerQuestions };
509
- const state = await handleStreamRequest(streamOptions, req, res, body, existingState);
510
-
511
- if (state && config.stateful && sessionId) {
512
- sessions.set(sessionId, state);
513
- }
514
- return;
515
- }
516
-
517
- // Not found
518
- sendJson(res, 404, { error: 'Not found' });
519
- } catch (error) {
520
- // M5: oversize body → 413 rather than a generic 500.
521
- if (errorStatusCode(error) === 413) {
522
- if (!res.headersSent) {
523
- sendJson(res, 413, { error: 'Payload Too Large', message: 'Request body exceeds the size limit' });
524
- }
525
- return;
526
- }
527
-
528
- // A malformed request body is the client's fault, not ours: return 400 with the
529
- // parse detail (which is safe — it describes the client's own JSON, not our
530
- // internals) rather than masking it as a 500 Internal Server Error.
531
- if (errorStatusCode(error) === 400) {
532
- if (!res.headersSent) {
533
- sendJson(res, 400, { error: 'Bad Request', message: (error as Error).message });
534
- }
535
- return;
536
- }
537
-
538
- logger.error('HTTP request error', {
539
- error,
540
- method: req.method,
541
- url,
542
- });
543
-
544
- if (!res.headersSent) {
545
- // M6: detail is logged above; the client gets only a generic message.
546
- sendJson(res, 500, {
547
- error: 'Internal server error',
548
- message: sanitizeError(error),
549
- });
550
- }
551
- }
552
- });
553
-
554
- // M5: bound how long a client may take to deliver headers/body so a slow-loris
555
- // connection cannot tie up a socket indefinitely. These cap request *intake*
556
- // time, not response duration, so long-lived SSE streams are unaffected.
557
- server.headersTimeout = 30_000; // 30s to send all request headers
558
- server.requestTimeout = 60_000; // 60s to deliver the complete request
559
- server.keepAliveTimeout = 5_000; // 5s idle on a keep-alive connection
560
- server.maxRequestsPerSocket = 0; // unlimited (default); explicit for clarity
561
-
562
- // Graceful shutdown handler
563
- let isShuttingDown = false;
564
-
565
- const gracefulShutdown = async (signal: string) => {
566
- if (isShuttingDown) return;
567
- isShuttingDown = true;
568
-
569
- logger.warn('Received shutdown signal, closing gracefully', { signal });
570
-
571
- // Close all MCP sessions
572
- for (const [sessionId, state] of sessions) {
573
- try {
574
- if ('server' in state) {
575
- await state.server.close();
576
- }
577
- if ('transport' in state && typeof state.transport.close === 'function') {
578
- await state.transport.close();
579
- }
580
- logger.info('Closed MCP session', { sessionId });
581
- } catch (error) {
582
- logger.error('Error closing MCP session', { sessionId, error });
583
- }
584
- }
585
- sessions.clear();
586
-
587
- // Close event subscriber
588
- ownerQuestions?.stop();
589
- if (eventSubscriber) {
590
- eventSubscriber.disconnect();
591
- }
592
-
593
- // Stop rate limiter
594
- if (rateLimiter) {
595
- rateLimiter.stop();
596
- }
597
-
598
- // Close HTTP server
599
- await new Promise<void>((resolve) => {
600
- server.close(() => {
601
- logger.info('HTTP server closed');
602
- resolve();
603
- });
604
- });
605
-
606
- process.exit(0);
607
- };
608
-
609
- process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
610
- process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
611
-
612
- // Start listening
613
- await new Promise<void>((resolve, reject) => {
614
- server.on('error', reject);
615
- server.listen(port, bindAddr, () => {
616
- logger.info('HTTP server listening', {
617
- port,
618
- bindAddr,
619
- url: `http://${bindAddr}:${port}${mcpPath}`,
620
- healthUrl: config.httpEnableHealthcheck ? `http://${bindAddr}:${port}${healthPath}` : undefined,
621
- sseEventsUrl: config.sseEventsEnabled && eventSubscriber ? `http://${bindAddr}:${port}${sseEventsPath}` : undefined,
622
- });
623
- resolve();
624
- });
625
- });
626
- }