@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,561 +0,0 @@
1
- /**
2
- * HTTP server tests
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
- import type { IncomingMessage, ServerResponse } from 'node:http';
7
- import type { HaClient } from '../../src/haClient/index.js';
8
- import type { EnvironmentConfig } from '../../src/config.js';
9
- import { VERSION } from '../../src/version.js';
10
- import {
11
- resolveForwardedProto,
12
- getOpenApiSpec,
13
- addRestApiCors,
14
- isRestApiCorsPath,
15
- isStatelessMcpGet,
16
- } from '../../src/server/http.js';
17
-
18
- // Mock dependencies
19
- vi.mock('node:http');
20
- vi.mock('../../src/server/stream.js');
21
- vi.mock('../../src/utils/logger.js');
22
-
23
- describe('HTTP Server', () => {
24
- let mockClient: HaClient;
25
- let mockConfig: EnvironmentConfig;
26
-
27
- beforeEach(() => {
28
- vi.clearAllMocks();
29
-
30
- mockClient = {
31
- checkApi: vi.fn().mockResolvedValue({ message: 'API running.' }),
32
- getStates: vi.fn(),
33
- getState: vi.fn(),
34
- callService: vi.fn(),
35
- getEntitiesByDomain: vi.fn(),
36
- searchEntities: vi.fn(),
37
- } as any;
38
-
39
- mockConfig = {
40
- baseUrl: 'http://homeassistant.10.0.0.19.nip.io:8123',
41
- token: 'test-token',
42
- strictSsl: false,
43
- timeout: 30000,
44
- aiProvider: 'ollama',
45
- aiUrl: 'http://ollama.10.0.0.17.nip.io:11434',
46
- aiModel: 'qwen3:14b',
47
- aiTimeout: 60000,
48
- logLevel: 'info',
49
- logFormat: 'plain',
50
- useHttp: true,
51
- stateful: false,
52
- httpPort: 3000,
53
- httpBindAddr: '127.0.0.1',
54
- httpPath: '/mcp',
55
- httpEnableHealthcheck: true,
56
- httpHealthcheckPath: '/health',
57
- httpAllowCors: true,
58
- httpAllowedOrigins: ['127.0.0.1', 'localhost'],
59
- sseEventsEnabled: true,
60
- sseEventsPath: '/subscribe_events',
61
- rateLimitEnabled: true,
62
- rateLimitWindowMs: 60000,
63
- rateLimitMaxRequests: 100,
64
- authMethod: 'none',
65
- };
66
- });
67
-
68
- describe('Health Check Handler', () => {
69
- it('should return health status for stream transport', () => {
70
- const expectedResponse = {
71
- status: 'healthy',
72
- version: VERSION,
73
- transport: 'stream',
74
- stateful: false,
75
- };
76
-
77
- expect(expectedResponse.status).toBe('healthy');
78
- expect(expectedResponse.transport).toBe('stream');
79
- expect(expectedResponse.stateful).toBe(false);
80
- });
81
-
82
- it('should include stateful flag when enabled', () => {
83
- const statefulConfig = { ...mockConfig, stateful: true };
84
-
85
- const expectedResponse = {
86
- status: 'healthy',
87
- version: VERSION,
88
- transport: 'stream',
89
- stateful: statefulConfig.stateful,
90
- };
91
-
92
- expect(expectedResponse.stateful).toBe(true);
93
- });
94
- });
95
-
96
- describe('CORS Handler', () => {
97
- it('should not set CORS headers when CORS is disabled', () => {
98
- const noCorsConfig = { ...mockConfig, httpAllowCors: false };
99
-
100
- expect(noCorsConfig.httpAllowCors).toBe(false);
101
- });
102
-
103
- it('should allow specific origins', () => {
104
- const allowedOrigins = ['http://mcpserver.10.0.0.18.nip.io:3000', 'http://192.168.0.10:8080'];
105
- const corsConfig = { ...mockConfig, httpAllowedOrigins: allowedOrigins };
106
-
107
- expect(corsConfig.httpAllowedOrigins).toContain('http://mcpserver.10.0.0.18.nip.io:3000');
108
- expect(corsConfig.httpAllowedOrigins).toContain('http://192.168.0.10:8080');
109
- });
110
-
111
- it('should allow all origins when list is empty (wildcard)', () => {
112
- const wildcardConfig = { ...mockConfig, httpAllowedOrigins: [] };
113
-
114
- // Empty array means wildcard was used
115
- expect(wildcardConfig.httpAllowedOrigins).toEqual([]);
116
- });
117
-
118
- it('should set correct CORS headers', () => {
119
- const expectedHeaders = {
120
- 'Access-Control-Allow-Origin': 'http://mcpserver.10.0.0.18.nip.io:3000',
121
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
122
- 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id',
123
- 'Access-Control-Max-Age': '86400',
124
- };
125
-
126
- expect(expectedHeaders['Access-Control-Allow-Methods']).toContain('GET');
127
- expect(expectedHeaders['Access-Control-Allow-Methods']).toContain('POST');
128
- expect(expectedHeaders['Access-Control-Allow-Headers']).toContain('Mcp-Session-Id');
129
- });
130
- });
131
-
132
- describe('Server Configuration', () => {
133
- it('should use default port if not specified', () => {
134
- const configWithoutPort = { ...mockConfig };
135
- delete configWithoutPort.httpPort;
136
-
137
- const port = configWithoutPort.httpPort ?? 3000;
138
- expect(port).toBe(3000);
139
- });
140
-
141
- it('should use default bind address if not specified', () => {
142
- const configWithoutBind = { ...mockConfig };
143
- delete configWithoutBind.httpBindAddr;
144
-
145
- const bindAddr = configWithoutBind.httpBindAddr ?? '127.0.0.1';
146
- expect(bindAddr).toBe('127.0.0.1');
147
- });
148
-
149
- it('should use default health check path if not specified', () => {
150
- const configWithoutHealthPath = { ...mockConfig };
151
- delete configWithoutHealthPath.httpHealthcheckPath;
152
-
153
- const healthPath = configWithoutHealthPath.httpHealthcheckPath ?? '/health';
154
- expect(healthPath).toBe('/health');
155
- });
156
-
157
- it('should support custom configuration', () => {
158
- const customConfig = {
159
- ...mockConfig,
160
- httpPort: 8080,
161
- httpBindAddr: '0.0.0.0',
162
- httpPath: '/custom-mcp',
163
- httpHealthcheckPath: '/status',
164
- };
165
-
166
- expect(customConfig.httpPort).toBe(8080);
167
- expect(customConfig.httpBindAddr).toBe('0.0.0.0');
168
- expect(customConfig.httpPath).toBe('/custom-mcp');
169
- expect(customConfig.httpHealthcheckPath).toBe('/status');
170
- });
171
- });
172
-
173
- describe('Request Routing', () => {
174
- it('should route to health check for /health', () => {
175
- const healthPath = mockConfig.httpHealthcheckPath;
176
- const requestUrl = '/health';
177
-
178
- expect(requestUrl).toBe(healthPath);
179
- });
180
-
181
- it('should route to MCP endpoint', () => {
182
- const mcpPath = mockConfig.httpPath;
183
- const requestUrl = '/mcp';
184
-
185
- expect(requestUrl).toBe(mcpPath);
186
- });
187
-
188
- it('should handle OPTIONS requests for CORS preflight', () => {
189
- const method = 'OPTIONS';
190
-
191
- expect(method).toBe('OPTIONS');
192
- // Should return 204 No Content
193
- });
194
-
195
- it('should return 404 for unknown paths', () => {
196
- const unknownPath = '/unknown-endpoint';
197
- const validPaths = [mockConfig.httpPath, mockConfig.httpHealthcheckPath];
198
-
199
- expect(validPaths).not.toContain(unknownPath);
200
- });
201
- });
202
-
203
- describe('Session Management', () => {
204
- it('should create new session when stateful mode is disabled', () => {
205
- expect(mockConfig.stateful).toBe(false);
206
- // Each request should create a new transport
207
- });
208
-
209
- it('should reuse session when stateful mode is enabled', () => {
210
- const statefulConfig = { ...mockConfig, stateful: true };
211
-
212
- expect(statefulConfig.stateful).toBe(true);
213
- // Should maintain sessions in Map
214
- });
215
-
216
- it('should require Mcp-Session-Id header for stateful mode', () => {
217
- const statefulConfig = {
218
- ...mockConfig,
219
- stateful: true,
220
- };
221
-
222
- expect(statefulConfig.stateful).toBe(true);
223
- // Should validate session ID from header
224
- });
225
-
226
- it('should return error for invalid session ID', () => {
227
- const statefulConfig = { ...mockConfig, stateful: true };
228
-
229
- expect(statefulConfig.stateful).toBe(true);
230
- // Should return 400 error for missing/invalid session
231
- });
232
- });
233
-
234
- describe('Error Handling', () => {
235
- it('should handle request parsing errors', () => {
236
- const invalidJson = 'not-valid-json';
237
-
238
- expect(() => JSON.parse(invalidJson)).toThrow();
239
- // Should return 500 with error message
240
- });
241
-
242
- it('should not send headers if already sent', () => {
243
- let headersSent = true;
244
-
245
- if (!headersSent) {
246
- // Would send error response
247
- }
248
-
249
- expect(headersSent).toBe(true);
250
- });
251
-
252
- it('should log errors with request details', () => {
253
- const error = new Error('Test error');
254
- const requestMethod = 'POST';
255
- const requestUrl = '/mcp';
256
-
257
- const logData = {
258
- error,
259
- method: requestMethod,
260
- url: requestUrl,
261
- };
262
-
263
- expect(logData.method).toBe('POST');
264
- expect(logData.url).toBe('/mcp');
265
- expect(logData.error).toBe(error);
266
- });
267
- });
268
-
269
- describe('JSON Response Helper', () => {
270
- it('should format JSON response correctly', () => {
271
- const statusCode = 200;
272
- const data = { status: 'healthy', version: VERSION };
273
-
274
- const expectedContentType = 'application/json';
275
- const expectedBody = JSON.stringify(data);
276
-
277
- expect(expectedContentType).toBe('application/json');
278
- expect(expectedBody).toBe(`{"status":"healthy","version":"${VERSION}"}`);
279
- });
280
-
281
- it('should handle error responses', () => {
282
- const statusCode = 500;
283
- const errorData = {
284
- error: 'Internal server error',
285
- message: 'Something went wrong',
286
- };
287
-
288
- const expectedBody = JSON.stringify(errorData);
289
-
290
- expect(statusCode).toBe(500);
291
- expect(JSON.parse(expectedBody).error).toBe('Internal server error');
292
- });
293
- });
294
-
295
- describe('Body Parsing', () => {
296
- it('should parse valid JSON body', () => {
297
- const jsonBody = '{"key":"value"}';
298
- const parsed = JSON.parse(jsonBody);
299
-
300
- expect(parsed).toEqual({ key: 'value' });
301
- });
302
-
303
- it('should return undefined for empty body', () => {
304
- const emptyBody = '';
305
-
306
- expect(emptyBody || undefined).toBeUndefined();
307
- });
308
-
309
- it('should throw error for invalid JSON', () => {
310
- const invalidJson = '{invalid}';
311
-
312
- expect(() => JSON.parse(invalidJson)).toThrow();
313
- });
314
- });
315
-
316
- describe('Response Timing', () => {
317
- it('should add Server-Timing header format', () => {
318
- // Server-Timing header format: total;dur=X (integer ms)
319
- const durationMs = 42.5;
320
- const expectedHeader = `total;dur=${Math.round(durationMs)}`;
321
-
322
- expect(expectedHeader).toBe('total;dur=43');
323
- expect(expectedHeader).toMatch(/^total;dur=\d+$/);
324
- });
325
-
326
- it('should wrap response end method', () => {
327
- const headers: Record<string, string> = {};
328
-
329
- const mockRes = {
330
- headersSent: false,
331
- setHeader: vi.fn((key: string, value: string) => {
332
- headers[key] = value;
333
- }),
334
- end: vi.fn(),
335
- } as unknown as ServerResponse;
336
-
337
- // Simulate the timing wrapper behavior
338
- const startTime = performance.now();
339
- const originalEnd = mockRes.end.bind(mockRes);
340
-
341
- mockRes.end = function () {
342
- if (!mockRes.headersSent) {
343
- const durationMs = Math.round(performance.now() - startTime);
344
- mockRes.setHeader('Server-Timing', `total;dur=${durationMs}`);
345
- }
346
- return originalEnd();
347
- } as typeof mockRes.end;
348
-
349
- // Call end
350
- mockRes.end();
351
-
352
- expect(headers['Server-Timing']).toMatch(/^total;dur=\d+$/);
353
- });
354
-
355
- it('should not set headers if already sent', () => {
356
- const headers: Record<string, string> = {};
357
-
358
- const mockRes = {
359
- headersSent: true, // Headers already sent
360
- setHeader: vi.fn((key: string, value: string) => {
361
- headers[key] = value;
362
- }),
363
- end: vi.fn(),
364
- } as unknown as ServerResponse;
365
-
366
- // Simulate the timing wrapper behavior
367
- const originalEnd = mockRes.end.bind(mockRes);
368
-
369
- mockRes.end = function () {
370
- if (!mockRes.headersSent) {
371
- mockRes.setHeader('Server-Timing', 'total;dur=0');
372
- }
373
- return originalEnd();
374
- } as typeof mockRes.end;
375
-
376
- mockRes.end();
377
-
378
- // Headers should not be set because headersSent was true
379
- expect(headers['Server-Timing']).toBeUndefined();
380
- });
381
-
382
- it('should measure actual elapsed time', async () => {
383
- const headers: Record<string, string> = {};
384
-
385
- const mockRes = {
386
- headersSent: false,
387
- setHeader: vi.fn((key: string, value: string) => {
388
- headers[key] = value;
389
- }),
390
- end: vi.fn(),
391
- } as unknown as ServerResponse;
392
-
393
- const startTime = performance.now();
394
- const originalEnd = mockRes.end.bind(mockRes);
395
-
396
- mockRes.end = function () {
397
- if (!mockRes.headersSent) {
398
- const durationMs = Math.round(performance.now() - startTime);
399
- mockRes.setHeader('Server-Timing', `total;dur=${durationMs}`);
400
- }
401
- return originalEnd();
402
- } as typeof mockRes.end;
403
-
404
- // Simulate some work
405
- await new Promise((resolve) => setTimeout(resolve, 10));
406
-
407
- mockRes.end();
408
-
409
- // Extract duration from header
410
- const timing = headers['Server-Timing'];
411
- const match = timing.match(/total;dur=(\d+)/);
412
- const duration = match ? parseInt(match[1], 10) : 0;
413
-
414
- // Real elapsed time was measured (not 0/instant). NOT asserted >= 10:
415
- // setTimeout(10) may fire a hair under 10ms and line 391 Math.round()s the
416
- // result, so a legitimate ~9.4ms run rounds to 9. That jitter is env-specific
417
- // (it tripped Node 22 in CI but not Node 20). A 5ms floor proves the timer was
418
- // captured without being brittle to sub-millisecond timer undershoot + rounding.
419
- expect(duration).toBeGreaterThanOrEqual(5);
420
- // But not too long (sanity check)
421
- expect(duration).toBeLessThan(1000);
422
- });
423
- });
424
-
425
- describe('OpenAPI spec scheme (X-Forwarded-Proto)', () => {
426
- it('returns https only when the header is exactly https', () => {
427
- expect(resolveForwardedProto('https')).toBe('https');
428
- expect(resolveForwardedProto('HTTPS')).toBe('https');
429
- expect(resolveForwardedProto(' https ')).toBe('https');
430
- });
431
-
432
- it('takes the first hop from a comma-separated proxy chain', () => {
433
- expect(resolveForwardedProto('https,http')).toBe('https');
434
- expect(resolveForwardedProto('http, https')).toBe('http');
435
- });
436
-
437
- it('falls back to http for missing, empty, or unknown values', () => {
438
- expect(resolveForwardedProto(undefined)).toBe('http');
439
- expect(resolveForwardedProto('')).toBe('http');
440
- expect(resolveForwardedProto('http')).toBe('http');
441
- // A spoofed/garbage scheme can never be injected into the advertised URL.
442
- expect(resolveForwardedProto('javascript')).toBe('http');
443
- expect(resolveForwardedProto('ftp')).toBe('http');
444
- });
445
-
446
- it('handles array-valued headers by using the first entry', () => {
447
- expect(resolveForwardedProto(['https', 'http'])).toBe('https');
448
- expect(resolveForwardedProto(['http'])).toBe('http');
449
- });
450
-
451
- it('advertises the resolved scheme + host as the OpenAPI server url', () => {
452
- // The regression: behind Cloudflare the spec must say https, not http, or the
453
- // browser blocks tool calls from the https Open WebUI page as mixed content.
454
- const scheme = resolveForwardedProto('https');
455
- const spec = getOpenApiSpec(`${scheme}://mcp.example.com`) as {
456
- servers: { url: string }[];
457
- info: { version: string };
458
- };
459
- expect(spec.servers[0].url).toBe('https://mcp.example.com');
460
- expect(spec.info.version).toBe(VERSION);
461
- });
462
- });
463
-
464
- describe('REST API CORS on pre-handler rejections', () => {
465
- const SSE_PATH = '/subscribe_events';
466
-
467
- it('identifies the browser-facing REST surface', () => {
468
- expect(isRestApiCorsPath('/openapi.json', '/openapi.json', SSE_PATH)).toBe(true);
469
- expect(isRestApiCorsPath('/api/version', '/api/version', SSE_PATH)).toBe(true);
470
- expect(isRestApiCorsPath('/api/states/light.x', '/api/states/light.x', SSE_PATH)).toBe(true);
471
- expect(isRestApiCorsPath('/subscribe_events', '/subscribe_events', SSE_PATH)).toBe(true);
472
- // Query string must not defeat the SSE path match (urlPath is path-only).
473
- expect(isRestApiCorsPath('/subscribe_events?token=x', '/subscribe_events', SSE_PATH)).toBe(true);
474
- });
475
-
476
- it('excludes MCP / health / unknown paths from the REST CORS surface', () => {
477
- expect(isRestApiCorsPath('/mcp', '/mcp', SSE_PATH)).toBe(false);
478
- expect(isRestApiCorsPath('/health', '/health', SSE_PATH)).toBe(false);
479
- expect(isRestApiCorsPath('/', '/', SSE_PATH)).toBe(false);
480
- // Must not be fooled by a path that merely contains "/api/".
481
- expect(isRestApiCorsPath('/not/api/x', '/not/api/x', SSE_PATH)).toBe(false);
482
- });
483
-
484
- it('reflects the request Origin in the CORS headers', () => {
485
- const set: Record<string, string> = {};
486
- const res = { setHeader: (k: string, v: string) => { set[k] = v; } } as unknown as ServerResponse;
487
- const req = { headers: { origin: 'https://owui.example.com' } } as unknown as IncomingMessage;
488
- addRestApiCors(req, res);
489
- expect(set['Access-Control-Allow-Origin']).toBe('https://owui.example.com');
490
- expect(set['Access-Control-Allow-Methods']).toContain('GET');
491
- expect(set['Access-Control-Allow-Headers']).toContain('Authorization');
492
- // Open WebUI sends x-session-id on every tool call; it must be allowed or the
493
- // browser blocks the request as a CORS preflight failure ("NetworkError").
494
- expect(set['Access-Control-Allow-Headers']).toContain('x-session-id');
495
- expect(set['Access-Control-Max-Age']).toBe('86400');
496
- });
497
-
498
- it('falls back to * when the request has no Origin', () => {
499
- const set: Record<string, string> = {};
500
- const res = { setHeader: (k: string, v: string) => { set[k] = v; } } as unknown as ServerResponse;
501
- const req = { headers: {} } as unknown as IncomingMessage;
502
- addRestApiCors(req, res);
503
- expect(set['Access-Control-Allow-Origin']).toBe('*');
504
- });
505
-
506
- it('REGRESSION: a 401 on /api/* still carries Access-Control-Allow-Origin', () => {
507
- // Reproduces the request pipeline ordering: REST CORS is applied for the path
508
- // BEFORE the auth middleware rejects, so the browser sees the real 401 instead
509
- // of an opaque "NetworkError when attempting to fetch resource".
510
- const set: Record<string, string> = {};
511
- let status = 0;
512
- const res = {
513
- setHeader: (k: string, v: string) => { set[k] = v; },
514
- writeHead: (code: number) => { status = code; },
515
- end: () => {},
516
- } as unknown as ServerResponse;
517
- const req = { headers: { origin: 'https://owui.example.com' } } as unknown as IncomingMessage;
518
-
519
- const url = '/api/version';
520
- // 1) dispatcher applies REST CORS for the path...
521
- if (isRestApiCorsPath(url, url, SSE_PATH)) {
522
- addRestApiCors(req, res);
523
- }
524
- // 2) ...then auth fails and writes a 401.
525
- const authOk = false;
526
- if (!authOk) {
527
- res.writeHead(401);
528
- res.end();
529
- }
530
-
531
- expect(status).toBe(401);
532
- expect(set['Access-Control-Allow-Origin']).toBe('https://owui.example.com');
533
- });
534
- });
535
-
536
- describe('stateless SSE GET rejection (isStatelessMcpGet)', () => {
537
- // REGRESSION: in stateless mode a GET /mcp opened the SDK's standalone SSE
538
- // stream on a fresh per-request transport that never emits and never ends —
539
- // an eternal zero-byte response that pinned the Cloudflare→origin keep-alive
540
- // socket and stalled every request queued behind it (mcp-remote's probe GET
541
- // hung its own subsequent initialize POST → "Request timed out"). The
542
- // dispatcher must refuse the GET with 405 before any transport work.
543
- it('rejects GET in stateless mode', () => {
544
- expect(isStatelessMcpGet(false, 'GET')).toBe(true);
545
- });
546
-
547
- it('allows GET in stateful mode (real server→client SSE stream exists)', () => {
548
- expect(isStatelessMcpGet(true, 'GET')).toBe(false);
549
- });
550
-
551
- it('never rejects POST or DELETE in either mode', () => {
552
- expect(isStatelessMcpGet(false, 'POST')).toBe(false);
553
- expect(isStatelessMcpGet(false, 'DELETE')).toBe(false);
554
- expect(isStatelessMcpGet(true, 'POST')).toBe(false);
555
- });
556
-
557
- it('does not reject an undefined method (let the transport answer it)', () => {
558
- expect(isStatelessMcpGet(false, undefined)).toBe(false);
559
- });
560
- });
561
- });