@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
package/src/server/http.ts
DELETED
|
@@ -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
|
-
}
|