@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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter Middleware
|
|
3
|
+
* Simple token bucket rate limiter for HTTP endpoints
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
export interface RateLimitConfig {
|
|
10
|
+
windowMs?: number; // Time window in milliseconds (default: 60000 = 1 minute)
|
|
11
|
+
maxRequests?: number; // Maximum requests per window (default: 100)
|
|
12
|
+
skipPaths?: string[]; // Paths to skip rate limiting (e.g., health checks)
|
|
13
|
+
keyGenerator?: (req: IncomingMessage) => string; // Custom key generator
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RateLimitEntry {
|
|
17
|
+
count: number;
|
|
18
|
+
windowStart: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rate Limiter class
|
|
23
|
+
*/
|
|
24
|
+
export class RateLimiter {
|
|
25
|
+
private readonly windowMs: number;
|
|
26
|
+
private readonly maxRequests: number;
|
|
27
|
+
private readonly skipPaths: Set<string>;
|
|
28
|
+
private readonly keyGenerator: (req: IncomingMessage) => string;
|
|
29
|
+
private readonly entries = new Map<string, RateLimitEntry>();
|
|
30
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
|
|
32
|
+
constructor(config: RateLimitConfig = {}) {
|
|
33
|
+
this.windowMs = config.windowMs ?? 60000; // 1 minute default
|
|
34
|
+
this.maxRequests = config.maxRequests ?? 100; // 100 requests per minute
|
|
35
|
+
this.skipPaths = new Set(config.skipPaths ?? ['/health', '/healthcheck']);
|
|
36
|
+
this.keyGenerator = config.keyGenerator ?? this.defaultKeyGenerator;
|
|
37
|
+
|
|
38
|
+
// Start cleanup interval to prevent memory leaks
|
|
39
|
+
this.startCleanup();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default key generator - uses IP address
|
|
44
|
+
*/
|
|
45
|
+
private defaultKeyGenerator(req: IncomingMessage): string {
|
|
46
|
+
// Try to get real IP from common proxy headers
|
|
47
|
+
const forwardedFor = req.headers['x-forwarded-for'];
|
|
48
|
+
if (forwardedFor) {
|
|
49
|
+
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
|
50
|
+
return ips.split(',')[0].trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const realIp = req.headers['x-real-ip'];
|
|
54
|
+
if (realIp) {
|
|
55
|
+
return Array.isArray(realIp) ? realIp[0] : realIp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fall back to socket remote address
|
|
59
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start periodic cleanup of expired entries
|
|
64
|
+
*/
|
|
65
|
+
private startCleanup(): void {
|
|
66
|
+
// Cleanup every window period
|
|
67
|
+
this.cleanupInterval = setInterval(() => {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
let cleaned = 0;
|
|
70
|
+
|
|
71
|
+
for (const [key, entry] of this.entries) {
|
|
72
|
+
if (now - entry.windowStart > this.windowMs * 2) {
|
|
73
|
+
this.entries.delete(key);
|
|
74
|
+
cleaned++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (cleaned > 0) {
|
|
79
|
+
logger.debug('Rate limiter cleanup', { entriesRemoved: cleaned, remaining: this.entries.size });
|
|
80
|
+
}
|
|
81
|
+
}, this.windowMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Stop cleanup interval
|
|
86
|
+
*/
|
|
87
|
+
stop(): void {
|
|
88
|
+
if (this.cleanupInterval) {
|
|
89
|
+
clearInterval(this.cleanupInterval);
|
|
90
|
+
this.cleanupInterval = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if request should be allowed
|
|
96
|
+
*/
|
|
97
|
+
check(req: IncomingMessage): { allowed: boolean; remaining: number; resetTime: number } {
|
|
98
|
+
const url = req.url ?? '/';
|
|
99
|
+
const path = url.split('?')[0];
|
|
100
|
+
|
|
101
|
+
// Skip rate limiting for certain paths
|
|
102
|
+
if (this.skipPaths.has(path)) {
|
|
103
|
+
return { allowed: true, remaining: this.maxRequests, resetTime: 0 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const key = this.keyGenerator(req);
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
let entry = this.entries.get(key);
|
|
109
|
+
|
|
110
|
+
// Create new entry or reset if window expired
|
|
111
|
+
if (!entry || now - entry.windowStart > this.windowMs) {
|
|
112
|
+
entry = { count: 0, windowStart: now };
|
|
113
|
+
this.entries.set(key, entry);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Increment count
|
|
117
|
+
entry.count++;
|
|
118
|
+
|
|
119
|
+
const remaining = Math.max(0, this.maxRequests - entry.count);
|
|
120
|
+
const resetTime = entry.windowStart + this.windowMs;
|
|
121
|
+
|
|
122
|
+
if (entry.count > this.maxRequests) {
|
|
123
|
+
logger.warn('Rate limit exceeded', {
|
|
124
|
+
key,
|
|
125
|
+
count: entry.count,
|
|
126
|
+
limit: this.maxRequests,
|
|
127
|
+
path,
|
|
128
|
+
});
|
|
129
|
+
return { allowed: false, remaining: 0, resetTime };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { allowed: true, remaining, resetTime };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Middleware function for HTTP server
|
|
137
|
+
*/
|
|
138
|
+
middleware(): (req: IncomingMessage, res: ServerResponse) => boolean {
|
|
139
|
+
return (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
140
|
+
const result = this.check(req);
|
|
141
|
+
|
|
142
|
+
// Add rate limit headers
|
|
143
|
+
res.setHeader('X-RateLimit-Limit', this.maxRequests.toString());
|
|
144
|
+
res.setHeader('X-RateLimit-Remaining', result.remaining.toString());
|
|
145
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000).toString());
|
|
146
|
+
|
|
147
|
+
if (!result.allowed) {
|
|
148
|
+
res.setHeader('Retry-After', Math.ceil((result.resetTime - Date.now()) / 1000).toString());
|
|
149
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
150
|
+
res.end(JSON.stringify({
|
|
151
|
+
error: 'Too Many Requests',
|
|
152
|
+
message: 'Rate limit exceeded. Please try again later.',
|
|
153
|
+
retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000),
|
|
154
|
+
}));
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get current stats
|
|
164
|
+
*/
|
|
165
|
+
getStats(): { totalEntries: number; config: { windowMs: number; maxRequests: number } } {
|
|
166
|
+
return {
|
|
167
|
+
totalEntries: this.entries.size,
|
|
168
|
+
config: {
|
|
169
|
+
windowMs: this.windowMs,
|
|
170
|
+
maxRequests: this.maxRequests,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Reset rate limit for a specific key
|
|
177
|
+
*/
|
|
178
|
+
reset(key: string): boolean {
|
|
179
|
+
return this.entries.delete(key);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Clear all rate limit entries
|
|
184
|
+
*/
|
|
185
|
+
clear(): void {
|
|
186
|
+
this.entries.clear();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a rate limiter instance with default config
|
|
192
|
+
*/
|
|
193
|
+
export function createRateLimiter(config?: RateLimitConfig): RateLimiter {
|
|
194
|
+
return new RateLimiter(config);
|
|
195
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) transport for MCP server
|
|
3
|
+
* Implements the legacy MCP protocol version 2024-11-05
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
8
|
+
import type { EnvironmentConfig } from '../config.js';
|
|
9
|
+
import type { HaClient } from '../haClient/index.js';
|
|
10
|
+
import type { LocalAIClient } from '../localAI/index.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
import { createServer } from './common.js';
|
|
13
|
+
|
|
14
|
+
const MESSAGE_PATH = '/messages';
|
|
15
|
+
|
|
16
|
+
interface SSETransportState {
|
|
17
|
+
transport: SSEServerTransport;
|
|
18
|
+
server: ReturnType<typeof createServer>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates an SSE transport for handling HTTP+SSE connections
|
|
23
|
+
*/
|
|
24
|
+
export function createSseTransport(
|
|
25
|
+
client: HaClient,
|
|
26
|
+
config: EnvironmentConfig,
|
|
27
|
+
endpoint: string,
|
|
28
|
+
res: ServerResponse,
|
|
29
|
+
aiClient?: LocalAIClient
|
|
30
|
+
): SSETransportState {
|
|
31
|
+
const mcpServer = createServer(client, aiClient);
|
|
32
|
+
|
|
33
|
+
const transport = new SSEServerTransport(endpoint, res, {
|
|
34
|
+
allowedOrigins: config.httpAllowedOrigins,
|
|
35
|
+
enableDnsRebindingProtection: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
transport.onerror = (error: Error) => {
|
|
39
|
+
logger.error('SSE transport error', {
|
|
40
|
+
error,
|
|
41
|
+
message: error.message,
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return { transport, server: mcpServer };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Handles SSE connection establishment (GET request)
|
|
50
|
+
*/
|
|
51
|
+
export async function handleSseConnection(
|
|
52
|
+
client: HaClient,
|
|
53
|
+
config: EnvironmentConfig,
|
|
54
|
+
endpoint: string,
|
|
55
|
+
req: IncomingMessage,
|
|
56
|
+
res: ServerResponse,
|
|
57
|
+
aiClient?: LocalAIClient
|
|
58
|
+
): Promise<SSETransportState> {
|
|
59
|
+
const originHeader = req.headers.origin;
|
|
60
|
+
const hostHeader = req.headers.host;
|
|
61
|
+
|
|
62
|
+
logger.info('SSE connection request received', {
|
|
63
|
+
method: req.method,
|
|
64
|
+
url: req.url,
|
|
65
|
+
origin: originHeader ?? '(not set)',
|
|
66
|
+
host: hostHeader ?? '(not set)',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const { transport, server } = createSseTransport(client, config, endpoint, res, aiClient);
|
|
71
|
+
|
|
72
|
+
await server.connect(transport);
|
|
73
|
+
await transport.start();
|
|
74
|
+
|
|
75
|
+
logger.info('SSE connection established', {
|
|
76
|
+
sessionId: transport.sessionId,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { transport, server };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error('Failed to establish SSE connection', {
|
|
82
|
+
error,
|
|
83
|
+
origin: originHeader ?? '(not set)',
|
|
84
|
+
host: hostHeader ?? '(not set)',
|
|
85
|
+
allowedOrigins: config.httpAllowedOrigins,
|
|
86
|
+
});
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handles SSE message POST requests
|
|
93
|
+
*/
|
|
94
|
+
export async function handleSseMessage(
|
|
95
|
+
transport: SSEServerTransport,
|
|
96
|
+
req: IncomingMessage,
|
|
97
|
+
res: ServerResponse,
|
|
98
|
+
parsedBody?: unknown
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
logger.debug('SSE message received', {
|
|
101
|
+
sessionId: transport.sessionId,
|
|
102
|
+
hasBody: !!parsedBody,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await transport.handlePostMessage(req, res, parsedBody);
|
|
106
|
+
|
|
107
|
+
logger.debug('SSE message handled', {
|
|
108
|
+
sessionId: transport.sessionId,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returns the message endpoint path for SSE transport
|
|
114
|
+
*/
|
|
115
|
+
export function getSseMessagePath(): string {
|
|
116
|
+
return MESSAGE_PATH;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Export type
|
|
120
|
+
export type { SSETransportState };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdio transport for MCP server
|
|
3
|
+
* Used for Claude Desktop and other stdio-based clients
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import type { HaClient } from '../haClient/index.js';
|
|
8
|
+
import type { LocalAIClient } from '../localAI/index.js';
|
|
9
|
+
import { logger } from '../utils/logger.js';
|
|
10
|
+
import { createServer } from './common.js';
|
|
11
|
+
|
|
12
|
+
export async function startStdioServer(client: HaClient, aiClient?: LocalAIClient): Promise<void> {
|
|
13
|
+
logger.info('Starting stdio server');
|
|
14
|
+
|
|
15
|
+
const server = createServer(client, aiClient);
|
|
16
|
+
const transport = new StdioServerTransport();
|
|
17
|
+
|
|
18
|
+
logger.info('Connecting stdio server');
|
|
19
|
+
await server.connect(transport);
|
|
20
|
+
|
|
21
|
+
logger.info('Stdio server connected and ready');
|
|
22
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP transport for MCP server
|
|
3
|
+
* Implements the MCP protocol version 2025-03-26 (current/recommended)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
8
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
9
|
+
import type { EnvironmentConfig } from '../config.js';
|
|
10
|
+
import type { HaClient } from '../haClient/index.js';
|
|
11
|
+
import type { LocalAIClient } from '../localAI/index.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
import { createServer } from './common.js';
|
|
14
|
+
|
|
15
|
+
interface StreamTransportState {
|
|
16
|
+
transport: StreamableHTTPServerTransport;
|
|
17
|
+
server: ReturnType<typeof createServer>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Export the type for use in http.ts
|
|
21
|
+
export type { StreamTransportState };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a Streamable HTTP transport
|
|
25
|
+
* This implements the MCP protocol version 2025-03-26
|
|
26
|
+
*/
|
|
27
|
+
export function createStreamTransport(client: HaClient, config: EnvironmentConfig, aiClient?: LocalAIClient): StreamTransportState {
|
|
28
|
+
const mcpServer = createServer(client, aiClient);
|
|
29
|
+
|
|
30
|
+
const enableStatefulSessions = config.stateful;
|
|
31
|
+
const sessionIdGenerator = enableStatefulSessions ? () => randomUUID() : undefined;
|
|
32
|
+
|
|
33
|
+
if (!enableStatefulSessions) {
|
|
34
|
+
logger.info('Starting Streamable HTTP transport in stateless mode; Mcp-Session-Id headers are optional');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const transport = new StreamableHTTPServerTransport({
|
|
38
|
+
sessionIdGenerator,
|
|
39
|
+
allowedOrigins: config.httpAllowedOrigins,
|
|
40
|
+
enableDnsRebindingProtection: true,
|
|
41
|
+
onsessioninitialized: (sessionId: string) => {
|
|
42
|
+
logger.info('Session initialized', { sessionId });
|
|
43
|
+
},
|
|
44
|
+
onsessionclosed: (sessionId: string) => {
|
|
45
|
+
logger.info('Session closed', { sessionId });
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
transport.onerror = (error: Error) => {
|
|
50
|
+
logger.error('Streamable HTTP transport error', {
|
|
51
|
+
error,
|
|
52
|
+
message: error.message,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { transport, server: mcpServer };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Handles incoming Streamable HTTP requests (GET, POST, DELETE)
|
|
61
|
+
* For stateful mode, transports should be stored and reused by the caller
|
|
62
|
+
*/
|
|
63
|
+
export async function handleStreamRequest(
|
|
64
|
+
client: HaClient,
|
|
65
|
+
config: EnvironmentConfig,
|
|
66
|
+
req: IncomingMessage,
|
|
67
|
+
res: ServerResponse,
|
|
68
|
+
parsedBody?: unknown,
|
|
69
|
+
existingTransport?: StreamTransportState,
|
|
70
|
+
aiClient?: LocalAIClient
|
|
71
|
+
): Promise<StreamTransportState | void> {
|
|
72
|
+
const originHeader = req.headers.origin;
|
|
73
|
+
const hostHeader = req.headers.host;
|
|
74
|
+
|
|
75
|
+
logger.info('Streamable HTTP request received', {
|
|
76
|
+
method: req.method,
|
|
77
|
+
url: req.url,
|
|
78
|
+
sessionId: req.headers['mcp-session-id'] ?? undefined,
|
|
79
|
+
origin: originHeader ?? '(not set)',
|
|
80
|
+
host: hostHeader ?? '(not set)',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Reuse existing transport if provided, otherwise create new one
|
|
84
|
+
const state = existingTransport ?? createStreamTransport(client, config, aiClient);
|
|
85
|
+
|
|
86
|
+
if (!existingTransport) {
|
|
87
|
+
await state.server.connect(state.transport);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await state.transport.handleRequest(req, res, parsedBody);
|
|
92
|
+
|
|
93
|
+
logger.debug('Streamable HTTP request handled', {
|
|
94
|
+
method: req.method,
|
|
95
|
+
sessionId: req.headers['mcp-session-id'] ?? undefined,
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
logger.error('Failed to handle Streamable HTTP request', {
|
|
99
|
+
error,
|
|
100
|
+
method: req.method,
|
|
101
|
+
url: req.url,
|
|
102
|
+
origin: originHeader ?? '(not set)',
|
|
103
|
+
host: hostHeader ?? '(not set)',
|
|
104
|
+
allowedOrigins: config.httpAllowedOrigins,
|
|
105
|
+
});
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Return state for session management if stateful
|
|
110
|
+
if (config.stateful) {
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call Service Tool
|
|
3
|
+
* Calls a Home Assistant service
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import {
|
|
8
|
+
CallToolRequestSchema,
|
|
9
|
+
ListToolsRequestSchema,
|
|
10
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import type { HaClient } from '../haClient/index.js';
|
|
12
|
+
import type { ServiceCallData } from '../types/index.js';
|
|
13
|
+
import { logger } from '../utils/logger.js';
|
|
14
|
+
|
|
15
|
+
export function registerCallServiceTool(server: Server, client: HaClient): void {
|
|
16
|
+
logger.debug('Registering callService tool');
|
|
17
|
+
|
|
18
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
19
|
+
return {
|
|
20
|
+
tools: [
|
|
21
|
+
{
|
|
22
|
+
name: 'callService',
|
|
23
|
+
description: 'Call a Home Assistant service (e.g., turn on a light, set temperature).',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
domain: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Service domain (e.g., "light", "switch", "climate", "notify")',
|
|
30
|
+
},
|
|
31
|
+
service: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Service name (e.g., "turn_on", "turn_off", "set_temperature")',
|
|
34
|
+
},
|
|
35
|
+
service_data: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
description: 'Optional service data (e.g., {"brightness": 255})',
|
|
38
|
+
},
|
|
39
|
+
target: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
description: 'Optional target (entity_id, device_id, or area_id)',
|
|
42
|
+
properties: {
|
|
43
|
+
entity_id: {
|
|
44
|
+
type: ['string', 'array'],
|
|
45
|
+
description: 'Entity ID or array of entity IDs',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ['domain', 'service'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
58
|
+
if (request.params.name !== 'callService') {
|
|
59
|
+
return {
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: JSON.stringify({ error: 'Unknown tool' }),
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const args = request.params.arguments as unknown as ServiceCallData;
|
|
72
|
+
|
|
73
|
+
if (!args.domain || !args.service) {
|
|
74
|
+
throw new Error('domain and service are required');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger.info('Executing callService tool', {
|
|
78
|
+
domain: args.domain,
|
|
79
|
+
service: args.service,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = await client.callService(args);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: JSON.stringify({
|
|
89
|
+
success: true,
|
|
90
|
+
context: result.context,
|
|
91
|
+
}, null, 2),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.error('Failed to call service', {
|
|
97
|
+
error: error instanceof Error ? error.message : String(error),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: JSON.stringify({
|
|
105
|
+
error: 'Failed to call service',
|
|
106
|
+
message: error instanceof Error ? error.message : String(error),
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get Entities By Domain Tool
|
|
3
|
+
* Gets all entities for a specific domain
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import {
|
|
8
|
+
CallToolRequestSchema,
|
|
9
|
+
ListToolsRequestSchema,
|
|
10
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import type { HaClient } from '../haClient/index.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
export function registerGetEntitiesByDomainTool(server: Server, client: HaClient): void {
|
|
15
|
+
logger.debug('Registering getEntitiesByDomain tool');
|
|
16
|
+
|
|
17
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
18
|
+
return {
|
|
19
|
+
tools: [
|
|
20
|
+
{
|
|
21
|
+
name: 'getEntitiesByDomain',
|
|
22
|
+
description: 'Get all entities for a specific domain (e.g., all lights, all sensors).',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
domain: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Domain name (e.g., "light", "sensor", "switch", "climate")',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ['domain'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
39
|
+
if (request.params.name !== 'getEntitiesByDomain') {
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: 'text',
|
|
44
|
+
text: JSON.stringify({ error: 'Unknown tool' }),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const { domain } = request.params.arguments as { domain: string };
|
|
53
|
+
|
|
54
|
+
if (!domain) {
|
|
55
|
+
throw new Error('domain is required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.info('Executing getEntitiesByDomain tool', { domain });
|
|
59
|
+
const entities = await client.getEntitiesByDomain(domain);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: JSON.stringify({
|
|
66
|
+
domain,
|
|
67
|
+
count: entities.length,
|
|
68
|
+
entities,
|
|
69
|
+
}, null, 2),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger.error('Failed to get entities by domain', {
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: 'text',
|
|
82
|
+
text: JSON.stringify({
|
|
83
|
+
error: 'Failed to get entities by domain',
|
|
84
|
+
message: error instanceof Error ? error.message : String(error),
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|