@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.
- package/.env.example +1 -1
- package/README.md +11 -2
- package/dist/omadaClient/client.d.ts +6 -1
- package/dist/omadaClient/client.js +12 -1
- package/dist/omadaClient/client.js.map +1 -1
- package/dist/omadaClient/diagnose.d.ts +68 -0
- package/dist/omadaClient/diagnose.js +164 -0
- package/dist/omadaClient/diagnose.js.map +1 -0
- package/dist/omadaClient/index.d.ts +6 -0
- package/dist/omadaClient/index.js +15 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/macGroups.d.ts +58 -0
- package/dist/omadaClient/macGroups.js +124 -0
- package/dist/omadaClient/macGroups.js.map +1 -0
- package/dist/tools/infra/actions.d.ts +1 -1
- package/dist/tools/infra/actions.js +1 -1
- package/dist/tools/omada/index.js +6 -0
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/macGroups.d.ts +19 -0
- package/dist/tools/omada/macGroups.js +77 -0
- package/dist/tools/omada/macGroups.js.map +1 -0
- package/dist/tools/omada/namespace.js +8 -0
- package/dist/tools/omada/namespace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
- package/.codex/review-rules.md +0 -16
- package/.dockerignore +0 -8
- package/.gitattributes +0 -30
- package/.github/workflows/ci.yml +0 -193
- package/AGENTS.md +0 -389
- package/Dockerfile +0 -50
- package/docker-compose.yml +0 -23
- package/docs/CHANGELOG.md +0 -341
- package/docs/COVERAGE.md +0 -219
- package/docs/QUICK_START.md +0 -240
- package/eslint.config.js +0 -33
- package/n8n/ai-agent-safety-monitor.json +0 -355
- package/openapi.json +0 -1259
- package/scripts/check-version-sync.mjs +0 -69
- package/scripts/generate-jwt.ts +0 -121
- package/src/config.ts +0 -440
- package/src/haClient/automations.ts +0 -261
- package/src/haClient/calendars.ts +0 -94
- package/src/haClient/config.ts +0 -52
- package/src/haClient/devices.ts +0 -653
- package/src/haClient/entities.ts +0 -128
- package/src/haClient/events.ts +0 -689
- package/src/haClient/history.ts +0 -73
- package/src/haClient/index.ts +0 -336
- package/src/haClient/request.ts +0 -142
- package/src/haClient/services.ts +0 -45
- package/src/haClient/states.ts +0 -60
- package/src/haClient/updates.ts +0 -58
- package/src/haClient/wsCommand.ts +0 -44
- package/src/index.ts +0 -164
- package/src/localAI/index.ts +0 -88
- package/src/localAI/prompts.ts +0 -50
- package/src/localAI/providers/base.ts +0 -120
- package/src/localAI/providers/ollama.ts +0 -78
- package/src/localAI/providers/openai.ts +0 -86
- package/src/localAI/types.ts +0 -67
- package/src/omadaClient/auth.ts +0 -178
- package/src/omadaClient/client.ts +0 -337
- package/src/omadaClient/device.ts +0 -118
- package/src/omadaClient/index.ts +0 -310
- package/src/omadaClient/network.ts +0 -284
- package/src/omadaClient/request.ts +0 -365
- package/src/omadaClient/security.ts +0 -51
- package/src/omadaClient/site.ts +0 -344
- package/src/ownerQuestions/service.ts +0 -536
- package/src/permissions/index.ts +0 -143
- package/src/resources/index.ts +0 -213
- package/src/server/auth.ts +0 -175
- package/src/server/chatFace.ts +0 -789
- package/src/server/chatSlice.ts +0 -57
- package/src/server/common.ts +0 -170
- package/src/server/eventSubscription.ts +0 -343
- package/src/server/http.ts +0 -626
- package/src/server/instructions.ts +0 -97
- package/src/server/rateLimiter.ts +0 -227
- package/src/server/stdio.ts +0 -41
- package/src/server/stream.ts +0 -205
- package/src/tools/ai/analyzeSensors.ts +0 -33
- package/src/tools/ai/index.ts +0 -20
- package/src/tools/common.ts +0 -442
- package/src/tools/homeassistant/askOwner.ts +0 -133
- package/src/tools/homeassistant/automationControls.ts +0 -66
- package/src/tools/homeassistant/calendar.ts +0 -97
- package/src/tools/homeassistant/callService.ts +0 -25
- package/src/tools/homeassistant/controlClimate.ts +0 -68
- package/src/tools/homeassistant/controlCover.ts +0 -48
- package/src/tools/homeassistant/controlFan.ts +0 -44
- package/src/tools/homeassistant/controlLight.ts +0 -38
- package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
- package/src/tools/homeassistant/createAutomation.ts +0 -35
- package/src/tools/homeassistant/deleteAutomation.ts +0 -24
- package/src/tools/homeassistant/entityAction.ts +0 -35
- package/src/tools/homeassistant/entityGuard.ts +0 -28
- package/src/tools/homeassistant/getAllSensors.ts +0 -46
- package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
- package/src/tools/homeassistant/getDomainSummary.ts +0 -29
- package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
- package/src/tools/homeassistant/getHistory.ts +0 -24
- package/src/tools/homeassistant/getState.ts +0 -24
- package/src/tools/homeassistant/getStates.ts +0 -46
- package/src/tools/homeassistant/getVersion.ts +0 -21
- package/src/tools/homeassistant/index.ts +0 -121
- package/src/tools/homeassistant/listAutomations.ts +0 -35
- package/src/tools/homeassistant/listEntities.ts +0 -44
- package/src/tools/homeassistant/listPersons.ts +0 -46
- package/src/tools/homeassistant/sceneAndScript.ts +0 -53
- package/src/tools/homeassistant/searchEntities.ts +0 -58
- package/src/tools/homeassistant/sendNotification.ts +0 -179
- package/src/tools/homeassistant/systemTools.ts +0 -52
- package/src/tools/homeassistant/triggerAutomation.ts +0 -24
- package/src/tools/index.ts +0 -78
- package/src/tools/infra/actions.ts +0 -95
- package/src/tools/infra/index.ts +0 -138
- package/src/tools/omada/blockClient.ts +0 -28
- package/src/tools/omada/cyclePoePort.ts +0 -32
- package/src/tools/omada/deleteClient.ts +0 -27
- package/src/tools/omada/disableClientRateLimit.ts +0 -23
- package/src/tools/omada/getClient.ts +0 -14
- package/src/tools/omada/getDevice.ts +0 -14
- package/src/tools/omada/getFirewallSetting.ts +0 -14
- package/src/tools/omada/getInternetInfo.ts +0 -14
- package/src/tools/omada/getLanNetworkList.ts +0 -14
- package/src/tools/omada/getLanProfileList.ts +0 -14
- package/src/tools/omada/getPortForwardingStatus.ts +0 -26
- package/src/tools/omada/getRateLimitProfiles.ts +0 -14
- package/src/tools/omada/getSiteNtpStatus.ts +0 -14
- package/src/tools/omada/getSsidDetail.ts +0 -22
- package/src/tools/omada/getSsidList.ts +0 -21
- package/src/tools/omada/getSwitchStackDetail.ts +0 -14
- package/src/tools/omada/getThreatList.ts +0 -42
- package/src/tools/omada/getWlanGroupList.ts +0 -14
- package/src/tools/omada/graph.ts +0 -187
- package/src/tools/omada/index.ts +0 -153
- package/src/tools/omada/listBlockedClients.ts +0 -17
- package/src/tools/omada/listClients.ts +0 -14
- package/src/tools/omada/listClientsActivity.ts +0 -25
- package/src/tools/omada/listClientsPastConnections.ts +0 -44
- package/src/tools/omada/listDevices.ts +0 -14
- package/src/tools/omada/listDevicesStats.ts +0 -41
- package/src/tools/omada/listMostActiveClients.ts +0 -14
- package/src/tools/omada/listSites.ts +0 -18
- package/src/tools/omada/namespace.ts +0 -1164
- package/src/tools/omada/searchDevices.ts +0 -20
- package/src/tools/omada/setClientRateLimit.ts +0 -26
- package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
- package/src/tools/omada/setSiteNtpServers.ts +0 -36
- package/src/tools/omada/setSsidEnabled.ts +0 -38
- package/src/tools/omada/unblockClient.ts +0 -26
- package/src/tools/types.ts +0 -11
- package/src/tools/utils/pagination-schema.ts +0 -27
- package/src/types/ai/index.ts +0 -11
- package/src/types/ai/types.ts +0 -67
- package/src/types/homeassistant/automations.ts +0 -43
- package/src/types/homeassistant/calendars.ts +0 -26
- package/src/types/homeassistant/config.ts +0 -14
- package/src/types/homeassistant/entities.ts +0 -14
- package/src/types/homeassistant/errors.ts +0 -33
- package/src/types/homeassistant/haConfig.ts +0 -7
- package/src/types/homeassistant/history.ts +0 -13
- package/src/types/homeassistant/index.ts +0 -46
- package/src/types/homeassistant/request.ts +0 -6
- package/src/types/homeassistant/serviceCall.ts +0 -18
- package/src/types/homeassistant/states.ts +0 -58
- package/src/types/homeassistant/updates.ts +0 -28
- package/src/types/index.ts +0 -12
- package/src/types/omada/activeClientInfo.ts +0 -17
- package/src/types/omada/clientActivity.ts +0 -32
- package/src/types/omada/clientBlockStatus.ts +0 -11
- package/src/types/omada/clientPastConnection.ts +0 -82
- package/src/types/omada/index.ts +0 -14
- package/src/types/omada/omadaApiResponse.ts +0 -5
- package/src/types/omada/omadaClientInfo.ts +0 -11
- package/src/types/omada/omadaDeviceInfo.ts +0 -11
- package/src/types/omada/omadaDeviceStats.ts +0 -31
- package/src/types/omada/omadaSiteSummary.ts +0 -5
- package/src/types/omada/oswStackDetail.ts +0 -11
- package/src/types/omada/paginatedResult.ts +0 -6
- package/src/types/omada/rateLimitProfile.ts +0 -58
- package/src/types/omada/threatInfo.ts +0 -38
- package/src/types/omada/tokenResult.ts +0 -6
- package/src/types.ts +0 -2
- package/src/utils/config-validations.ts +0 -68
- package/src/utils/jwt.ts +0 -112
- package/src/utils/logger.ts +0 -121
- package/src/utils/plainText.ts +0 -48
- package/src/utils/redact.ts +0 -59
- package/src/utils/sanitizeError.ts +0 -18
- package/src/utils/tlsDispatcher.ts +0 -69
- package/src/version.ts +0 -5
- package/tests/bdd/automation-creation.spec.ts +0 -214
- package/tests/bdd/automation-management.spec.ts +0 -267
- package/tests/bdd/calendar.spec.ts +0 -190
- package/tests/bdd/configuration.spec.ts +0 -283
- package/tests/bdd/device-control.spec.ts +0 -344
- package/tests/bdd/entity-listing.spec.ts +0 -208
- package/tests/bdd/entity-search.spec.ts +0 -183
- package/tests/bdd/history-retrieval.spec.ts +0 -195
- package/tests/bdd/notifications.spec.ts +0 -238
- package/tests/bdd/service-calls.spec.ts +0 -144
- package/tests/bdd/state-retrieval.spec.ts +0 -204
- package/tests/bench/hotpaths.bench.ts +0 -68
- package/tests/config.test.ts +0 -355
- package/tests/features/automation-creation.feature +0 -36
- package/tests/features/automation-management.feature +0 -54
- package/tests/features/calendar.feature +0 -37
- package/tests/features/configuration.feature +0 -70
- package/tests/features/device-control.feature +0 -100
- package/tests/features/entity-listing.feature +0 -51
- package/tests/features/entity-search.feature +0 -44
- package/tests/features/history-retrieval.feature +0 -38
- package/tests/features/notifications.feature +0 -37
- package/tests/features/service-calls.feature +0 -35
- package/tests/features/state-retrieval.feature +0 -37
- package/tests/fixtures/openapi-default-slice.json +0 -290
- package/tests/haClient/automationTrace.test.ts +0 -59
- package/tests/haClient/automations.test.ts +0 -149
- package/tests/haClient/config.test.ts +0 -117
- package/tests/haClient/entities.test.ts +0 -188
- package/tests/haClient/events.test.ts +0 -455
- package/tests/haClient/eventsTls.test.ts +0 -52
- package/tests/haClient/history.test.ts +0 -228
- package/tests/haClient/index.test.ts +0 -243
- package/tests/haClient/request.test.ts +0 -217
- package/tests/haClient/services.test.ts +0 -120
- package/tests/haClient/states.test.ts +0 -119
- package/tests/haClient/updates.test.ts +0 -193
- package/tests/index.test.ts +0 -160
- package/tests/integration/homeassistant.test.ts +0 -144
- package/tests/integration/omada.test.ts +0 -167
- package/tests/integration/server.test.ts +0 -151
- package/tests/localAI/index.test.ts +0 -177
- package/tests/localAI/ollama.test.ts +0 -187
- package/tests/localAI/openai.test.ts +0 -257
- package/tests/omadaClient/actions.test.ts +0 -161
- package/tests/omadaClient/clientBlocks.test.ts +0 -170
- package/tests/omadaClient/ntp.test.ts +0 -381
- package/tests/omadaClient/site.test.ts +0 -37
- package/tests/ownerQuestions/service.test.ts +0 -499
- package/tests/ownerQuestions/tools.test.ts +0 -186
- package/tests/permissions/index.test.ts +0 -217
- package/tests/resources/index.test.ts +0 -288
- package/tests/scripts/generateJwt.test.ts +0 -44
- package/tests/security/accessControl.test.ts +0 -223
- package/tests/security/authentication.test.ts +0 -117
- package/tests/security/configuration.test.ts +0 -170
- package/tests/security/cryptoTransport.test.ts +0 -172
- package/tests/security/dependencies.test.ts +0 -152
- package/tests/security/injection.test.ts +0 -117
- package/tests/security/insecureDesign.test.ts +0 -121
- package/tests/security/logging.test.ts +0 -131
- package/tests/security/resourceConsumption.test.ts +0 -142
- package/tests/server/auth.test.ts +0 -249
- package/tests/server/chatFace.test.ts +0 -320
- package/tests/server/common.test.ts +0 -84
- package/tests/server/http.test.ts +0 -561
- package/tests/server/instructions.test.ts +0 -152
- package/tests/server/memoizeJsonSchema.test.ts +0 -71
- package/tests/server/stdio.test.ts +0 -99
- package/tests/server/stream.test.ts +0 -285
- package/tests/server/toolVisibility.http.test.ts +0 -111
- package/tests/server/toolVisibility.test.ts +0 -129
- package/tests/tools/ai/analyzeSensors.test.ts +0 -227
- package/tests/tools/common.test.ts +0 -123
- package/tests/tools/homeassistant/handlers.test.ts +0 -872
- package/tests/tools/infra/invokeAction.test.ts +0 -280
- package/tests/tools/omada/graph.test.ts +0 -385
- package/tests/tools/omada/handlers.test.ts +0 -897
- package/tests/tools/omada/namespace.test.ts +0 -483
- package/tests/tools/registry.test.ts +0 -168
- package/tests/utils/config-validations.test.ts +0 -352
- package/tests/utils/jwt.test.ts +0 -115
- package/tests/utils/logger.test.ts +0 -327
- package/tests/utils/redact.test.ts +0 -105
- package/tsconfig.json +0 -24
- 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
|
-
});
|