@coffeerunhobby/mcp-ha-connect 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +8 -0
- package/.env.example +149 -0
- package/.gitattributes +30 -0
- package/Dockerfile +33 -0
- package/LICENSE +21 -0
- package/README.md +394 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/haClient/automations.d.ts +67 -0
- package/dist/haClient/automations.d.ts.map +1 -0
- package/dist/haClient/automations.js +181 -0
- package/dist/haClient/automations.js.map +1 -0
- package/dist/haClient/config.d.ts +28 -0
- package/dist/haClient/config.d.ts.map +1 -0
- package/dist/haClient/config.js +38 -0
- package/dist/haClient/config.js.map +1 -0
- package/dist/haClient/devices.d.ts +219 -0
- package/dist/haClient/devices.d.ts.map +1 -0
- package/dist/haClient/devices.js +506 -0
- package/dist/haClient/devices.js.map +1 -0
- package/dist/haClient/entities.d.ts +35 -0
- package/dist/haClient/entities.d.ts.map +1 -0
- package/dist/haClient/entities.js +97 -0
- package/dist/haClient/entities.js.map +1 -0
- package/dist/haClient/events.d.ts +138 -0
- package/dist/haClient/events.d.ts.map +1 -0
- package/dist/haClient/events.js +344 -0
- package/dist/haClient/events.js.map +1 -0
- package/dist/haClient/history.d.ts +26 -0
- package/dist/haClient/history.d.ts.map +1 -0
- package/dist/haClient/history.js +54 -0
- package/dist/haClient/history.js.map +1 -0
- package/dist/haClient/index.d.ts +153 -0
- package/dist/haClient/index.d.ts.map +1 -0
- package/dist/haClient/index.js +223 -0
- package/dist/haClient/index.js.map +1 -0
- package/dist/haClient/request.d.ts +34 -0
- package/dist/haClient/request.d.ts.map +1 -0
- package/dist/haClient/request.js +93 -0
- package/dist/haClient/request.js.map +1 -0
- package/dist/haClient/services.d.ts +22 -0
- package/dist/haClient/services.d.ts.map +1 -0
- package/dist/haClient/services.js +39 -0
- package/dist/haClient/services.js.map +1 -0
- package/dist/haClient/states.d.ts +26 -0
- package/dist/haClient/states.d.ts.map +1 -0
- package/dist/haClient/states.js +53 -0
- package/dist/haClient/states.js.map +1 -0
- package/dist/haClient/updates.d.ts +22 -0
- package/dist/haClient/updates.d.ts.map +1 -0
- package/dist/haClient/updates.js +48 -0
- package/dist/haClient/updates.js.map +1 -0
- package/dist/haClient.d.ts +97 -0
- package/dist/haClient.d.ts.map +1 -0
- package/dist/haClient.js +344 -0
- package/dist/haClient.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/dist/localAI/index.d.ts +37 -0
- package/dist/localAI/index.d.ts.map +1 -0
- package/dist/localAI/index.js +73 -0
- package/dist/localAI/index.js.map +1 -0
- package/dist/localAI/prompts.d.ts +20 -0
- package/dist/localAI/prompts.d.ts.map +1 -0
- package/dist/localAI/prompts.js +48 -0
- package/dist/localAI/prompts.js.map +1 -0
- package/dist/localAI/providers/base.d.ts +51 -0
- package/dist/localAI/providers/base.d.ts.map +1 -0
- package/dist/localAI/providers/base.js +80 -0
- package/dist/localAI/providers/base.js.map +1 -0
- package/dist/localAI/providers/ollama.d.ts +22 -0
- package/dist/localAI/providers/ollama.d.ts.map +1 -0
- package/dist/localAI/providers/ollama.js +63 -0
- package/dist/localAI/providers/ollama.js.map +1 -0
- package/dist/localAI/providers/openai.d.ts +23 -0
- package/dist/localAI/providers/openai.d.ts.map +1 -0
- package/dist/localAI/providers/openai.js +63 -0
- package/dist/localAI/providers/openai.js.map +1 -0
- package/dist/localAI/types.d.ts +61 -0
- package/dist/localAI/types.d.ts.map +1 -0
- package/dist/localAI/types.js +6 -0
- package/dist/localAI/types.js.map +1 -0
- package/dist/ollamaClient.d.ts +37 -0
- package/dist/ollamaClient.d.ts.map +1 -0
- package/dist/ollamaClient.js +110 -0
- package/dist/ollamaClient.js.map +1 -0
- package/dist/resources/index.d.ts +11 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +238 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/server/common.d.ts +9 -0
- package/dist/server/common.d.ts.map +1 -0
- package/dist/server/common.js +27 -0
- package/dist/server/common.js.map +1 -0
- package/dist/server/eventSubscription.d.ts +53 -0
- package/dist/server/eventSubscription.d.ts.map +1 -0
- package/dist/server/eventSubscription.js +257 -0
- package/dist/server/eventSubscription.js.map +1 -0
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +491 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/rateLimiter.d.ts +70 -0
- package/dist/server/rateLimiter.d.ts.map +1 -0
- package/dist/server/rateLimiter.js +156 -0
- package/dist/server/rateLimiter.js.map +1 -0
- package/dist/server/sse.d.ts +32 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +76 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/stdio.d.ts +8 -0
- package/dist/server/stdio.d.ts.map +1 -0
- package/dist/server/stdio.js +16 -0
- package/dist/server/stdio.js.map +1 -0
- package/dist/server/stream.d.ts +26 -0
- package/dist/server/stream.d.ts.map +1 -0
- package/dist/server/stream.js +81 -0
- package/dist/server/stream.js.map +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +30 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/callService.d.ts +8 -0
- package/dist/tools/callService.d.ts.map +1 -0
- package/dist/tools/callService.js +100 -0
- package/dist/tools/callService.js.map +1 -0
- package/dist/tools/getClient.d.ts +8 -0
- package/dist/tools/getClient.d.ts.map +1 -0
- package/dist/tools/getClient.js +94 -0
- package/dist/tools/getClient.js.map +1 -0
- package/dist/tools/getDevice.d.ts +8 -0
- package/dist/tools/getDevice.d.ts.map +1 -0
- package/dist/tools/getDevice.js +94 -0
- package/dist/tools/getDevice.js.map +1 -0
- package/dist/tools/getEntitiesByDomain.d.ts +8 -0
- package/dist/tools/getEntitiesByDomain.d.ts.map +1 -0
- package/dist/tools/getEntitiesByDomain.js +80 -0
- package/dist/tools/getEntitiesByDomain.js.map +1 -0
- package/dist/tools/getState.d.ts +8 -0
- package/dist/tools/getState.d.ts.map +1 -0
- package/dist/tools/getState.js +90 -0
- package/dist/tools/getState.js.map +1 -0
- package/dist/tools/getStates.d.ts +8 -0
- package/dist/tools/getStates.d.ts.map +1 -0
- package/dist/tools/getStates.js +70 -0
- package/dist/tools/getStates.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/listClients.d.ts +8 -0
- package/dist/tools/listClients.d.ts.map +1 -0
- package/dist/tools/listClients.js +73 -0
- package/dist/tools/listClients.js.map +1 -0
- package/dist/tools/listDevices.d.ts +8 -0
- package/dist/tools/listDevices.d.ts.map +1 -0
- package/dist/tools/listDevices.js +73 -0
- package/dist/tools/listDevices.js.map +1 -0
- package/dist/tools/listSites.d.ts +8 -0
- package/dist/tools/listSites.d.ts.map +1 -0
- package/dist/tools/listSites.js +67 -0
- package/dist/tools/listSites.js.map +1 -0
- package/dist/tools/registry.d.ts +9 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +1382 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/searchDevices.d.ts +8 -0
- package/dist/tools/searchDevices.d.ts.map +1 -0
- package/dist/tools/searchDevices.js +84 -0
- package/dist/tools/searchDevices.js.map +1 -0
- package/dist/tools/searchEntities.d.ts +8 -0
- package/dist/tools/searchEntities.d.ts.map +1 -0
- package/dist/tools/searchEntities.js +80 -0
- package/dist/tools/searchEntities.js.map +1 -0
- package/dist/types/entity.d.ts +98 -0
- package/dist/types/entity.d.ts.map +1 -0
- package/dist/types/entity.js +2 -0
- package/dist/types/entity.js.map +1 -0
- package/dist/types/errors.d.ts +16 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +32 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/haConfig.d.ts +8 -0
- package/dist/types/haConfig.d.ts.map +1 -0
- package/dist/types/haConfig.js +2 -0
- package/dist/types/haConfig.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/request.d.ts +7 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +2 -0
- package/dist/types/request.js.map +1 -0
- package/dist/types/serviceCall.d.ts +18 -0
- package/dist/types/serviceCall.d.ts.map +1 -0
- package/dist/types/serviceCall.js +2 -0
- package/dist/types/serviceCall.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/config-validations.d.ts +12 -0
- package/dist/utils/config-validations.d.ts.map +1 -0
- package/dist/utils/config-validations.js +43 -0
- package/dist/utils/config-validations.js.map +1 -0
- package/dist/utils/logger.d.ts +24 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +94 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/wrapper.d.ts +8 -0
- package/dist/wrapper.d.ts.map +1 -0
- package/dist/wrapper.js +301 -0
- package/dist/wrapper.js.map +1 -0
- package/docker-compose.yml +17 -0
- package/docs/CHANGELOG.md +86 -0
- package/docs/LOCAL_CLIENT.md +268 -0
- package/docs/SSE_API.md +341 -0
- package/eslint.config.js +33 -0
- package/n8n/ai-agent-safety-monitor.json +355 -0
- package/openapi.json +166 -0
- package/package.json +51 -0
- package/src/config.ts +260 -0
- package/src/haClient/automations.ts +217 -0
- package/src/haClient/config.ts +41 -0
- package/src/haClient/devices.ts +653 -0
- package/src/haClient/entities.ts +128 -0
- package/src/haClient/events.ts +456 -0
- package/src/haClient/history.ts +68 -0
- package/src/haClient/index.ts +287 -0
- package/src/haClient/request.ts +127 -0
- package/src/haClient/services.ts +45 -0
- package/src/haClient/states.ts +60 -0
- package/src/haClient/updates.ts +58 -0
- package/src/index.ts +99 -0
- package/src/localAI/index.ts +88 -0
- package/src/localAI/prompts.ts +50 -0
- package/src/localAI/providers/base.ts +120 -0
- package/src/localAI/providers/ollama.ts +78 -0
- package/src/localAI/providers/openai.ts +86 -0
- package/src/localAI/types.ts +67 -0
- package/src/resources/index.ts +266 -0
- package/src/server/common.ts +38 -0
- package/src/server/eventSubscription.ts +318 -0
- package/src/server/http.ts +546 -0
- package/src/server/rateLimiter.ts +195 -0
- package/src/server/sse.ts +120 -0
- package/src/server/stdio.ts +22 -0
- package/src/server/stream.ts +113 -0
- package/src/tools/callService.ts +114 -0
- package/src/tools/getEntitiesByDomain.ts +92 -0
- package/src/tools/getState.ts +103 -0
- package/src/tools/getStates.ts +80 -0
- package/src/tools/index.ts +9 -0
- package/src/tools/registry.ts +1521 -0
- package/src/tools/searchEntities.ts +92 -0
- package/src/types/entity.ts +110 -0
- package/src/types/errors.ts +33 -0
- package/src/types/haConfig.ts +7 -0
- package/src/types/index.ts +5 -0
- package/src/types/request.ts +6 -0
- package/src/types/serviceCall.ts +18 -0
- package/src/types.ts +2 -0
- package/src/utils/config-validations.ts +46 -0
- package/src/utils/logger.ts +114 -0
- package/src/wrapper.ts +340 -0
- package/tests/config.test.ts +332 -0
- package/tests/haClient/automations.test.ts +133 -0
- package/tests/haClient/config.test.ts +96 -0
- package/tests/haClient/entities.test.ts +188 -0
- package/tests/haClient/history.test.ts +170 -0
- package/tests/haClient/index.test.ts +221 -0
- package/tests/haClient/request.test.ts +217 -0
- package/tests/haClient/services.test.ts +120 -0
- package/tests/haClient/states.test.ts +119 -0
- package/tests/haClient/updates.test.ts +193 -0
- package/tests/index.test.ts +164 -0
- package/tests/localAI/index.test.ts +168 -0
- package/tests/localAI/ollama.test.ts +187 -0
- package/tests/localAI/openai.test.ts +257 -0
- package/tests/server/common.test.ts +64 -0
- package/tests/server/http.test.ts +326 -0
- package/tests/server/sse.test.ts +240 -0
- package/tests/server/stdio.test.ts +102 -0
- package/tests/server/stream.test.ts +287 -0
- package/tests/tools/registry.test.ts +355 -0
- package/tests/utils/config-validations.test.ts +289 -0
- package/tests/utils/logger.test.ts +327 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server for MCP with SSE or Streamable transports
|
|
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 { logger } from '../utils/logger.js';
|
|
12
|
+
import { handleSseConnection, handleSseMessage, getSseMessagePath, type SSETransportState } from './sse.js';
|
|
13
|
+
import { handleStreamRequest, type StreamTransportState } from './stream.js';
|
|
14
|
+
import { handleEventSubscription, getClientCount } from './eventSubscription.js';
|
|
15
|
+
import { RateLimiter } from './rateLimiter.js';
|
|
16
|
+
|
|
17
|
+
// Session storage for stateful mode
|
|
18
|
+
const sessions = new Map<string, SSETransportState | StreamTransportState>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse JSON body from request
|
|
22
|
+
*/
|
|
23
|
+
function parseBody(req: IncomingMessage): Promise<unknown> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let body = '';
|
|
26
|
+
req.on('data', (chunk) => (body += chunk.toString()));
|
|
27
|
+
req.on('end', () => {
|
|
28
|
+
if (!body) {
|
|
29
|
+
resolve(undefined);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
resolve(JSON.parse(body));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
reject(new Error(`Invalid JSON: ${(error as Error).message}`));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
req.on('error', reject);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send JSON response
|
|
44
|
+
*/
|
|
45
|
+
function sendJson(res: ServerResponse, statusCode: number, data: unknown): void {
|
|
46
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
47
|
+
res.end(JSON.stringify(data));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle health check endpoint
|
|
52
|
+
*/
|
|
53
|
+
function handleHealthCheck(res: ServerResponse, config: EnvironmentConfig, aiEnabled: boolean, sseEnabled: boolean): void {
|
|
54
|
+
sendJson(res, 200, {
|
|
55
|
+
status: 'healthy',
|
|
56
|
+
version: '0.5.0',
|
|
57
|
+
transport: config.httpTransport,
|
|
58
|
+
stateful: config.stateful,
|
|
59
|
+
aiEnabled,
|
|
60
|
+
aiProvider: aiEnabled ? config.aiProvider : undefined,
|
|
61
|
+
aiUrl: aiEnabled ? config.aiUrl : undefined,
|
|
62
|
+
aiModel: aiEnabled ? config.aiModel : undefined,
|
|
63
|
+
sseEventsEnabled: sseEnabled,
|
|
64
|
+
sseEventsPath: sseEnabled ? config.sseEventsPath : undefined,
|
|
65
|
+
sseConnectedClients: sseEnabled ? getClientCount() : undefined,
|
|
66
|
+
rateLimitEnabled: config.rateLimitEnabled,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Add CORS headers for REST API endpoints (always enabled for Open WebUI compatibility)
|
|
72
|
+
*/
|
|
73
|
+
function addRestApiCors(req: IncomingMessage, res: ServerResponse): void {
|
|
74
|
+
const origin = req.headers.origin;
|
|
75
|
+
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
|
76
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
77
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
78
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle REST API requests for Open WebUI compatibility
|
|
83
|
+
* These endpoints translate REST calls to Home Assistant API calls
|
|
84
|
+
*/
|
|
85
|
+
async function handleRestApi(
|
|
86
|
+
req: IncomingMessage,
|
|
87
|
+
res: ServerResponse,
|
|
88
|
+
url: string,
|
|
89
|
+
client: HaClient
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
// Always add CORS for REST API
|
|
92
|
+
addRestApiCors(req, res);
|
|
93
|
+
|
|
94
|
+
const parsedUrl = new URL(url, `http://${req.headers.host}`);
|
|
95
|
+
const pathname = parsedUrl.pathname;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// GET /api/states - Get all entity states
|
|
99
|
+
if (req.method === 'GET' && pathname === '/api/states') {
|
|
100
|
+
const states = await client.getStates();
|
|
101
|
+
sendJson(res, 200, states);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// GET /api/states/{entity_id} - Get specific entity state
|
|
106
|
+
const stateMatch = pathname.match(/^\/api\/states\/([^/]+)$/);
|
|
107
|
+
if (req.method === 'GET' && stateMatch) {
|
|
108
|
+
const entityId = decodeURIComponent(stateMatch[1]);
|
|
109
|
+
const state = await client.getState(entityId);
|
|
110
|
+
if (state) {
|
|
111
|
+
sendJson(res, 200, state);
|
|
112
|
+
} else {
|
|
113
|
+
sendJson(res, 404, { error: `Entity ${entityId} not found` });
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// GET /api/sensors - Get all sensors
|
|
119
|
+
if (req.method === 'GET' && pathname === '/api/sensors') {
|
|
120
|
+
const states = await client.getStates();
|
|
121
|
+
const sensors = states.filter(
|
|
122
|
+
(s: { entity_id: string }) =>
|
|
123
|
+
s.entity_id.startsWith('sensor.') || s.entity_id.startsWith('binary_sensor.')
|
|
124
|
+
);
|
|
125
|
+
sendJson(res, 200, sensors);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// GET /api/entities/{domain} - Get entities by domain
|
|
130
|
+
const domainMatch = pathname.match(/^\/api\/entities\/([^/]+)$/);
|
|
131
|
+
if (req.method === 'GET' && domainMatch) {
|
|
132
|
+
const domain = decodeURIComponent(domainMatch[1]);
|
|
133
|
+
const states = await client.getStates();
|
|
134
|
+
const filtered = states.filter((s: { entity_id: string }) =>
|
|
135
|
+
s.entity_id.startsWith(`${domain}.`)
|
|
136
|
+
);
|
|
137
|
+
sendJson(res, 200, filtered);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// GET /api/search?q={query} - Search entities
|
|
142
|
+
if (req.method === 'GET' && pathname === '/api/search') {
|
|
143
|
+
const query = parsedUrl.searchParams.get('q')?.toLowerCase() ?? '';
|
|
144
|
+
const states = await client.getStates();
|
|
145
|
+
const filtered = states.filter(
|
|
146
|
+
(s: { entity_id: string; attributes?: { friendly_name?: string } }) =>
|
|
147
|
+
s.entity_id.toLowerCase().includes(query) ||
|
|
148
|
+
s.attributes?.friendly_name?.toLowerCase().includes(query)
|
|
149
|
+
);
|
|
150
|
+
sendJson(res, 200, filtered);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// GET /api/history/{entity_id}?hours={hours} - Get entity history
|
|
155
|
+
const historyMatch = pathname.match(/^\/api\/history\/([^/]+)$/);
|
|
156
|
+
if (req.method === 'GET' && historyMatch) {
|
|
157
|
+
const entityId = decodeURIComponent(historyMatch[1]);
|
|
158
|
+
const hours = parseInt(parsedUrl.searchParams.get('hours') ?? '24', 10);
|
|
159
|
+
const history = await client.getHistory(entityId, hours);
|
|
160
|
+
sendJson(res, 200, history);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// POST /api/services/{domain}/{service} - Call a service
|
|
165
|
+
const serviceMatch = pathname.match(/^\/api\/services\/([^/]+)\/([^/]+)$/);
|
|
166
|
+
if (req.method === 'POST' && serviceMatch) {
|
|
167
|
+
const domain = decodeURIComponent(serviceMatch[1]);
|
|
168
|
+
const service = decodeURIComponent(serviceMatch[2]);
|
|
169
|
+
const body = (await parseBody(req)) as { entity_id?: string; data?: Record<string, unknown> } | undefined;
|
|
170
|
+
const result = await client.callService({
|
|
171
|
+
domain,
|
|
172
|
+
service,
|
|
173
|
+
target: body?.entity_id ? { entity_id: body.entity_id } : undefined,
|
|
174
|
+
service_data: body?.data,
|
|
175
|
+
});
|
|
176
|
+
sendJson(res, 200, result);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Not found
|
|
181
|
+
sendJson(res, 404, { error: 'API endpoint not found' });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logger.error('REST API error', { error, url });
|
|
184
|
+
sendJson(res, 500, {
|
|
185
|
+
error: 'Internal server error',
|
|
186
|
+
message: error instanceof Error ? error.message : String(error),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate OpenAPI spec for Open WebUI compatibility
|
|
193
|
+
*/
|
|
194
|
+
function getOpenApiSpec(baseUrl: string): object {
|
|
195
|
+
return {
|
|
196
|
+
openapi: '3.1.0',
|
|
197
|
+
info: {
|
|
198
|
+
title: 'Home Assistant MCP Tools',
|
|
199
|
+
description: 'MCP server for Home Assistant - exposes entity states and service calls',
|
|
200
|
+
version: '0.2.0',
|
|
201
|
+
},
|
|
202
|
+
servers: [{ url: baseUrl }],
|
|
203
|
+
paths: {
|
|
204
|
+
'/api/states': {
|
|
205
|
+
get: {
|
|
206
|
+
operationId: 'getStates',
|
|
207
|
+
summary: 'Get all entity states from Home Assistant',
|
|
208
|
+
responses: {
|
|
209
|
+
'200': {
|
|
210
|
+
description: 'List of all entity states',
|
|
211
|
+
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
'/api/states/{entity_id}': {
|
|
217
|
+
get: {
|
|
218
|
+
operationId: 'getState',
|
|
219
|
+
summary: 'Get state of a specific entity',
|
|
220
|
+
parameters: [{ name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID (e.g., light.living_room)' }],
|
|
221
|
+
responses: {
|
|
222
|
+
'200': {
|
|
223
|
+
description: 'Entity state',
|
|
224
|
+
content: { 'application/json': { schema: { $ref: '#/components/schemas/EntityState' } } },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
'/api/services/{domain}/{service}': {
|
|
230
|
+
post: {
|
|
231
|
+
operationId: 'callService',
|
|
232
|
+
summary: 'Call a Home Assistant service',
|
|
233
|
+
parameters: [
|
|
234
|
+
{ name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Service domain (e.g., light, switch)' },
|
|
235
|
+
{ name: 'service', in: 'path', required: true, schema: { type: 'string' }, description: 'Service name (e.g., turn_on, turn_off)' },
|
|
236
|
+
],
|
|
237
|
+
requestBody: {
|
|
238
|
+
content: {
|
|
239
|
+
'application/json': {
|
|
240
|
+
schema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
entity_id: { type: 'string', description: 'Target entity ID' },
|
|
244
|
+
data: { type: 'object', description: 'Service data' },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
responses: { '200': { description: 'Service call result' } },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
'/api/sensors': {
|
|
254
|
+
get: {
|
|
255
|
+
operationId: 'getAllSensors',
|
|
256
|
+
summary: 'Get all sensor and binary_sensor states',
|
|
257
|
+
responses: {
|
|
258
|
+
'200': {
|
|
259
|
+
description: 'List of sensor states',
|
|
260
|
+
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
'/api/entities/{domain}': {
|
|
266
|
+
get: {
|
|
267
|
+
operationId: 'getEntitiesByDomain',
|
|
268
|
+
summary: 'Get all entities for a specific domain',
|
|
269
|
+
parameters: [{ name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Domain name (e.g., light, sensor)' }],
|
|
270
|
+
responses: {
|
|
271
|
+
'200': {
|
|
272
|
+
description: 'List of entities in domain',
|
|
273
|
+
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
'/api/search': {
|
|
279
|
+
get: {
|
|
280
|
+
operationId: 'searchEntities',
|
|
281
|
+
summary: 'Search entities by name or ID',
|
|
282
|
+
parameters: [{ name: 'q', in: 'query', required: true, schema: { type: 'string' }, description: 'Search query' }],
|
|
283
|
+
responses: {
|
|
284
|
+
'200': {
|
|
285
|
+
description: 'Matching entities',
|
|
286
|
+
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/EntityState' } } } },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
'/api/history/{entity_id}': {
|
|
292
|
+
get: {
|
|
293
|
+
operationId: 'getHistory',
|
|
294
|
+
summary: 'Get historical data for an entity',
|
|
295
|
+
parameters: [
|
|
296
|
+
{ name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID' },
|
|
297
|
+
{ name: 'hours', in: 'query', schema: { type: 'number', default: 24 }, description: 'Hours of history' },
|
|
298
|
+
],
|
|
299
|
+
responses: {
|
|
300
|
+
'200': {
|
|
301
|
+
description: 'Historical data',
|
|
302
|
+
content: { 'application/json': { schema: { type: 'array' } } },
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
components: {
|
|
309
|
+
schemas: {
|
|
310
|
+
EntityState: {
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
entity_id: { type: 'string' },
|
|
314
|
+
state: { type: 'string' },
|
|
315
|
+
attributes: { type: 'object' },
|
|
316
|
+
last_changed: { type: 'string' },
|
|
317
|
+
last_updated: { type: 'string' },
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Handle CORS preflight
|
|
327
|
+
*/
|
|
328
|
+
function handleCors(req: IncomingMessage, res: ServerResponse, config: EnvironmentConfig): void {
|
|
329
|
+
if (!config.httpAllowCors) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const origin = req.headers.origin;
|
|
334
|
+
const allowedOrigins = config.httpAllowedOrigins ?? [];
|
|
335
|
+
|
|
336
|
+
// If origins list is empty, allow all (wildcard was used)
|
|
337
|
+
if (allowedOrigins.length === 0 || (origin && allowedOrigins.includes(origin))) {
|
|
338
|
+
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
|
339
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
340
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
|
|
341
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Start HTTP server
|
|
347
|
+
*/
|
|
348
|
+
export async function startHttpServer(client: HaClient, config: EnvironmentConfig, aiClient?: LocalAIClient): Promise<void> {
|
|
349
|
+
const port = config.httpPort ?? 3000;
|
|
350
|
+
const bindAddr = config.httpBindAddr ?? '127.0.0.1';
|
|
351
|
+
const mcpPath = config.httpPath ?? '/mcp';
|
|
352
|
+
const healthPath = config.httpHealthcheckPath ?? '/health';
|
|
353
|
+
const sseEventsPath = config.sseEventsPath ?? '/subscribe_events';
|
|
354
|
+
|
|
355
|
+
// Initialize rate limiter if enabled
|
|
356
|
+
const rateLimiter = config.rateLimitEnabled
|
|
357
|
+
? new RateLimiter({
|
|
358
|
+
windowMs: config.rateLimitWindowMs,
|
|
359
|
+
maxRequests: config.rateLimitMaxRequests,
|
|
360
|
+
skipPaths: [healthPath, '/openapi.json'],
|
|
361
|
+
})
|
|
362
|
+
: null;
|
|
363
|
+
|
|
364
|
+
// Initialize event subscriber if SSE events are enabled
|
|
365
|
+
let eventSubscriber: EventSubscriber | null = null;
|
|
366
|
+
if (config.sseEventsEnabled) {
|
|
367
|
+
eventSubscriber = new EventSubscriber({
|
|
368
|
+
baseUrl: config.baseUrl,
|
|
369
|
+
token: config.token,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Connect to Home Assistant WebSocket
|
|
373
|
+
try {
|
|
374
|
+
await eventSubscriber.connect();
|
|
375
|
+
logger.info('Event subscriber connected to Home Assistant');
|
|
376
|
+
} catch (error) {
|
|
377
|
+
logger.warn('Failed to connect event subscriber, SSE events will be unavailable', {
|
|
378
|
+
error: error instanceof Error ? error.message : String(error),
|
|
379
|
+
});
|
|
380
|
+
eventSubscriber = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
logger.info('Starting HTTP server', {
|
|
385
|
+
port,
|
|
386
|
+
bindAddr,
|
|
387
|
+
transport: config.httpTransport,
|
|
388
|
+
mcpPath,
|
|
389
|
+
healthPath,
|
|
390
|
+
sseEventsPath: config.sseEventsEnabled ? sseEventsPath : undefined,
|
|
391
|
+
stateful: config.stateful,
|
|
392
|
+
aiEnabled: !!aiClient,
|
|
393
|
+
rateLimitEnabled: config.rateLimitEnabled,
|
|
394
|
+
sseEventsEnabled: config.sseEventsEnabled && !!eventSubscriber,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
398
|
+
const url = req.url ?? '/';
|
|
399
|
+
const urlPath = url.split('?')[0];
|
|
400
|
+
|
|
401
|
+
// Handle OPTIONS (CORS preflight) - add CORS headers for REST API endpoints
|
|
402
|
+
if (req.method === 'OPTIONS') {
|
|
403
|
+
if (url === '/openapi.json' || url.startsWith('/api/') || urlPath === sseEventsPath) {
|
|
404
|
+
addRestApiCors(req, res);
|
|
405
|
+
} else {
|
|
406
|
+
handleCors(req, res, config);
|
|
407
|
+
}
|
|
408
|
+
res.writeHead(204);
|
|
409
|
+
res.end();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Apply rate limiting (skip health checks and certain paths)
|
|
414
|
+
if (rateLimiter) {
|
|
415
|
+
const allowed = rateLimiter.middleware()(req, res);
|
|
416
|
+
if (!allowed) {
|
|
417
|
+
return; // Response already sent by rate limiter
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Add CORS headers for MCP endpoints
|
|
422
|
+
handleCors(req, res, config);
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// Health check endpoint
|
|
426
|
+
if (config.httpEnableHealthcheck && url === healthPath) {
|
|
427
|
+
handleHealthCheck(res, config, !!aiClient, !!eventSubscriber);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// SSE Event Subscription endpoint
|
|
432
|
+
if (config.sseEventsEnabled && eventSubscriber && urlPath === sseEventsPath) {
|
|
433
|
+
if (req.method === 'GET') {
|
|
434
|
+
addRestApiCors(req, res);
|
|
435
|
+
await handleEventSubscription(req, res, eventSubscriber);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
sendJson(res, 405, { error: 'Method not allowed. Use GET for SSE subscription.' });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// OpenAPI spec endpoint for Open WebUI compatibility
|
|
443
|
+
if (req.method === 'GET' && url === '/openapi.json') {
|
|
444
|
+
addRestApiCors(req, res);
|
|
445
|
+
const baseUrl = `http://${req.headers.host ?? `${bindAddr}:${port}`}`;
|
|
446
|
+
sendJson(res, 200, getOpenApiSpec(baseUrl));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// REST API endpoints for Open WebUI compatibility
|
|
451
|
+
if (url.startsWith('/api/')) {
|
|
452
|
+
await handleRestApi(req, res, url, client);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// SSE Transport
|
|
457
|
+
if (config.httpTransport === 'sse') {
|
|
458
|
+
const messagePath = getSseMessagePath();
|
|
459
|
+
|
|
460
|
+
// SSE connection (GET)
|
|
461
|
+
if (req.method === 'GET' && url === mcpPath) {
|
|
462
|
+
const state = await handleSseConnection(client, config, mcpPath, req, res, aiClient);
|
|
463
|
+
if (config.stateful && state.transport.sessionId) {
|
|
464
|
+
sessions.set(state.transport.sessionId, state);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// SSE message (POST)
|
|
470
|
+
if (req.method === 'POST' && url === messagePath) {
|
|
471
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
472
|
+
const state = sessionId ? sessions.get(sessionId) as SSETransportState | undefined : undefined;
|
|
473
|
+
|
|
474
|
+
if (!state || !('sessionId' in state.transport)) {
|
|
475
|
+
sendJson(res, 400, { error: 'Invalid or missing session' });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const body = await parseBody(req);
|
|
480
|
+
await handleSseMessage(state.transport, req, res, body);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Streamable HTTP Transport
|
|
486
|
+
if (config.httpTransport === 'stream') {
|
|
487
|
+
if (url === mcpPath) {
|
|
488
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
489
|
+
const existingState = sessionId ? sessions.get(sessionId) : undefined;
|
|
490
|
+
|
|
491
|
+
const body = req.method !== 'GET' ? await parseBody(req) : undefined;
|
|
492
|
+
const state = await handleStreamRequest(client, config, req, res, body, existingState as StreamTransportState, aiClient);
|
|
493
|
+
|
|
494
|
+
if (state && config.stateful && sessionId) {
|
|
495
|
+
sessions.set(sessionId, state);
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Not found
|
|
502
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
503
|
+
} catch (error) {
|
|
504
|
+
logger.error('HTTP request error', {
|
|
505
|
+
error,
|
|
506
|
+
method: req.method,
|
|
507
|
+
url,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (!res.headersSent) {
|
|
511
|
+
sendJson(res, 500, {
|
|
512
|
+
error: 'Internal server error',
|
|
513
|
+
message: error instanceof Error ? error.message : String(error),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Handle server shutdown
|
|
520
|
+
const cleanup = () => {
|
|
521
|
+
if (eventSubscriber) {
|
|
522
|
+
eventSubscriber.disconnect();
|
|
523
|
+
}
|
|
524
|
+
if (rateLimiter) {
|
|
525
|
+
rateLimiter.stop();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
process.on('SIGTERM', cleanup);
|
|
530
|
+
process.on('SIGINT', cleanup);
|
|
531
|
+
|
|
532
|
+
// Start listening
|
|
533
|
+
await new Promise<void>((resolve, reject) => {
|
|
534
|
+
server.on('error', reject);
|
|
535
|
+
server.listen(port, bindAddr, () => {
|
|
536
|
+
logger.info('HTTP server listening', {
|
|
537
|
+
port,
|
|
538
|
+
bindAddr,
|
|
539
|
+
url: `http://${bindAddr}:${port}${mcpPath}`,
|
|
540
|
+
healthUrl: config.httpEnableHealthcheck ? `http://${bindAddr}:${port}${healthPath}` : undefined,
|
|
541
|
+
sseEventsUrl: config.sseEventsEnabled && eventSubscriber ? `http://${bindAddr}:${port}${sseEventsPath}` : undefined,
|
|
542
|
+
});
|
|
543
|
+
resolve();
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|