@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.1

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 (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. 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
- }