@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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resources Registry
|
|
3
|
+
* Provides URI-based access to Home Assistant entities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import {
|
|
8
|
+
ListResourcesRequestSchema,
|
|
9
|
+
ReadResourceRequestSchema,
|
|
10
|
+
ListResourceTemplatesRequestSchema,
|
|
11
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import type { HaClient } from '../haClient/index.js';
|
|
13
|
+
import { logger } from '../utils/logger.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register all Home Assistant resources with the MCP server
|
|
17
|
+
*/
|
|
18
|
+
export function registerAllResources(server: Server, client: HaClient): void {
|
|
19
|
+
logger.debug('Registering MCP resources');
|
|
20
|
+
|
|
21
|
+
// List available resource templates
|
|
22
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
23
|
+
return {
|
|
24
|
+
resourceTemplates: [
|
|
25
|
+
{
|
|
26
|
+
uriTemplate: 'hass://entities/{entity_id}',
|
|
27
|
+
name: 'Entity State',
|
|
28
|
+
description: 'Get the current state of a specific Home Assistant entity',
|
|
29
|
+
mimeType: 'application/json',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
uriTemplate: 'hass://entities/{entity_id}/detailed',
|
|
33
|
+
name: 'Entity Detailed',
|
|
34
|
+
description: 'Get detailed information about an entity including all attributes',
|
|
35
|
+
mimeType: 'application/json',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
uriTemplate: 'hass://entities/domain/{domain}',
|
|
39
|
+
name: 'Domain Entities',
|
|
40
|
+
description: 'Get all entities for a specific domain',
|
|
41
|
+
mimeType: 'application/json',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
uriTemplate: 'hass://search/{query}/{limit}',
|
|
45
|
+
name: 'Search Entities',
|
|
46
|
+
description: 'Search for entities matching a query with result limit',
|
|
47
|
+
mimeType: 'application/json',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// List concrete resources (the static entities resource)
|
|
54
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
55
|
+
return {
|
|
56
|
+
resources: [
|
|
57
|
+
{
|
|
58
|
+
uri: 'hass://entities',
|
|
59
|
+
name: 'All Entities',
|
|
60
|
+
description: 'List all Home Assistant entities grouped by domain',
|
|
61
|
+
mimeType: 'application/json',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Read resource content
|
|
68
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
69
|
+
const uri = request.params.uri;
|
|
70
|
+
logger.debug('Reading resource', { uri });
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Parse the URI
|
|
74
|
+
const url = new URL(uri);
|
|
75
|
+
const path = url.pathname;
|
|
76
|
+
|
|
77
|
+
// hass://entities - List all entities grouped by domain
|
|
78
|
+
if (uri === 'hass://entities') {
|
|
79
|
+
const entities = await client.getStates();
|
|
80
|
+
|
|
81
|
+
// Group by domain
|
|
82
|
+
const grouped: Record<string, Array<{ entity_id: string; state: string; friendly_name?: string }>> = {};
|
|
83
|
+
for (const entity of entities) {
|
|
84
|
+
const domain = entity.entity_id.split('.')[0];
|
|
85
|
+
if (!grouped[domain]) {
|
|
86
|
+
grouped[domain] = [];
|
|
87
|
+
}
|
|
88
|
+
grouped[domain].push({
|
|
89
|
+
entity_id: entity.entity_id,
|
|
90
|
+
state: entity.state,
|
|
91
|
+
friendly_name: entity.attributes.friendly_name as string | undefined,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
contents: [
|
|
97
|
+
{
|
|
98
|
+
uri,
|
|
99
|
+
mimeType: 'application/json',
|
|
100
|
+
text: JSON.stringify({
|
|
101
|
+
total: entities.length,
|
|
102
|
+
domains: Object.keys(grouped).length,
|
|
103
|
+
entities: grouped,
|
|
104
|
+
}, null, 2),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// hass://entities/{entity_id}/detailed - Detailed entity info
|
|
111
|
+
if (path.endsWith('/detailed')) {
|
|
112
|
+
const entityId = path.replace('/detailed', '').replace(/^\//, '');
|
|
113
|
+
const entity = await client.getState(entityId);
|
|
114
|
+
|
|
115
|
+
if (!entity) {
|
|
116
|
+
return {
|
|
117
|
+
contents: [
|
|
118
|
+
{
|
|
119
|
+
uri,
|
|
120
|
+
mimeType: 'application/json',
|
|
121
|
+
text: JSON.stringify({ error: 'Entity not found', entity_id: entityId }),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
contents: [
|
|
129
|
+
{
|
|
130
|
+
uri,
|
|
131
|
+
mimeType: 'application/json',
|
|
132
|
+
text: JSON.stringify({
|
|
133
|
+
entity_id: entity.entity_id,
|
|
134
|
+
state: entity.state,
|
|
135
|
+
attributes: entity.attributes,
|
|
136
|
+
last_changed: entity.last_changed,
|
|
137
|
+
last_updated: entity.last_updated,
|
|
138
|
+
context: entity.context,
|
|
139
|
+
}, null, 2),
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// hass://entities/domain/{domain} - Entities by domain
|
|
146
|
+
if (path.startsWith('/domain/')) {
|
|
147
|
+
const domain = path.replace('/domain/', '');
|
|
148
|
+
const entities = await client.getEntitiesByDomain(domain);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
contents: [
|
|
152
|
+
{
|
|
153
|
+
uri,
|
|
154
|
+
mimeType: 'application/json',
|
|
155
|
+
text: JSON.stringify({
|
|
156
|
+
domain,
|
|
157
|
+
count: entities.length,
|
|
158
|
+
entities: entities.map(e => ({
|
|
159
|
+
entity_id: e.entity_id,
|
|
160
|
+
state: e.state,
|
|
161
|
+
friendly_name: e.attributes.friendly_name,
|
|
162
|
+
})),
|
|
163
|
+
}, null, 2),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// hass://search/{query}/{limit} - Search entities
|
|
170
|
+
if (path.startsWith('/search/')) {
|
|
171
|
+
const parts = path.replace('/search/', '').split('/');
|
|
172
|
+
const query = decodeURIComponent(parts[0] || '');
|
|
173
|
+
const limit = parseInt(parts[1] || '50', 10);
|
|
174
|
+
|
|
175
|
+
let entities = await client.searchEntities(query);
|
|
176
|
+
if (limit > 0) {
|
|
177
|
+
entities = entities.slice(0, limit);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
contents: [
|
|
182
|
+
{
|
|
183
|
+
uri,
|
|
184
|
+
mimeType: 'application/json',
|
|
185
|
+
text: JSON.stringify({
|
|
186
|
+
query,
|
|
187
|
+
limit,
|
|
188
|
+
count: entities.length,
|
|
189
|
+
entities: entities.map(e => ({
|
|
190
|
+
entity_id: e.entity_id,
|
|
191
|
+
state: e.state,
|
|
192
|
+
friendly_name: e.attributes.friendly_name,
|
|
193
|
+
})),
|
|
194
|
+
}, null, 2),
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// hass://entities/{entity_id} - Simple entity state
|
|
201
|
+
const entityId = path.replace(/^\//, '');
|
|
202
|
+
if (entityId && !entityId.includes('/')) {
|
|
203
|
+
const entity = await client.getState(entityId);
|
|
204
|
+
|
|
205
|
+
if (!entity) {
|
|
206
|
+
return {
|
|
207
|
+
contents: [
|
|
208
|
+
{
|
|
209
|
+
uri,
|
|
210
|
+
mimeType: 'application/json',
|
|
211
|
+
text: JSON.stringify({ error: 'Entity not found', entity_id: entityId }),
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
contents: [
|
|
219
|
+
{
|
|
220
|
+
uri,
|
|
221
|
+
mimeType: 'application/json',
|
|
222
|
+
text: JSON.stringify({
|
|
223
|
+
entity_id: entity.entity_id,
|
|
224
|
+
state: entity.state,
|
|
225
|
+
friendly_name: entity.attributes.friendly_name,
|
|
226
|
+
last_changed: entity.last_changed,
|
|
227
|
+
}, null, 2),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Unknown resource
|
|
234
|
+
return {
|
|
235
|
+
contents: [
|
|
236
|
+
{
|
|
237
|
+
uri,
|
|
238
|
+
mimeType: 'application/json',
|
|
239
|
+
text: JSON.stringify({ error: 'Unknown resource', uri }),
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
} catch (error) {
|
|
245
|
+
logger.error('Failed to read resource', {
|
|
246
|
+
uri,
|
|
247
|
+
error: error instanceof Error ? error.message : String(error),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
contents: [
|
|
252
|
+
{
|
|
253
|
+
uri,
|
|
254
|
+
mimeType: 'application/json',
|
|
255
|
+
text: JSON.stringify({
|
|
256
|
+
error: 'Failed to read resource',
|
|
257
|
+
message: error instanceof Error ? error.message : String(error),
|
|
258
|
+
}),
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
logger.info('MCP resources registered successfully', { resourceCount: 5 });
|
|
266
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common MCP server creation
|
|
3
|
+
* Shared across all transport types (stdio, SSE, stream)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import type { HaClient } from '../haClient/index.js';
|
|
8
|
+
import type { LocalAIClient } from '../localAI/index.js';
|
|
9
|
+
import { registerAllTools } from '../tools/registry.js';
|
|
10
|
+
import { registerAllResources } from '../resources/index.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
|
|
13
|
+
export function createServer(client: HaClient, aiClient?: LocalAIClient): Server {
|
|
14
|
+
logger.debug('Creating MCP server instance');
|
|
15
|
+
|
|
16
|
+
const server = new Server(
|
|
17
|
+
{
|
|
18
|
+
name: 'mcp-ha-connect',
|
|
19
|
+
version: '0.4.0',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
capabilities: {
|
|
23
|
+
tools: {},
|
|
24
|
+
resources: {},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Register all Home Assistant tools
|
|
30
|
+
registerAllTools(server, client, aiClient);
|
|
31
|
+
|
|
32
|
+
// Register all Home Assistant resources
|
|
33
|
+
registerAllResources(server, client);
|
|
34
|
+
|
|
35
|
+
logger.debug('MCP server instance created with 15 tools and 5 resources registered');
|
|
36
|
+
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE Event Subscription Endpoint
|
|
3
|
+
* Allows HTTP clients to subscribe to Home Assistant events via Server-Sent Events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import type { EventSubscriber, HaEvent } from '../haClient/events.js';
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
export interface SSEClient {
|
|
11
|
+
id: string;
|
|
12
|
+
res: ServerResponse;
|
|
13
|
+
subscriptionIds: string[];
|
|
14
|
+
domain?: string;
|
|
15
|
+
entityId?: string;
|
|
16
|
+
eventTypes?: string[];
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
lastEventAt?: Date;
|
|
19
|
+
eventCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Store active SSE clients
|
|
23
|
+
const clients = new Map<string, SSEClient>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse query parameters from URL
|
|
27
|
+
*/
|
|
28
|
+
function parseQueryParams(url: string): URLSearchParams {
|
|
29
|
+
const queryIndex = url.indexOf('?');
|
|
30
|
+
if (queryIndex === -1) {
|
|
31
|
+
return new URLSearchParams();
|
|
32
|
+
}
|
|
33
|
+
return new URLSearchParams(url.slice(queryIndex + 1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send SSE event to a client
|
|
38
|
+
*/
|
|
39
|
+
function sendSSEEvent(res: ServerResponse, event: string, data: unknown): void {
|
|
40
|
+
if (res.writableEnded) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
45
|
+
res.write(`event: ${event}\n`);
|
|
46
|
+
res.write(`data: ${payload}\n\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Send SSE comment (keep-alive)
|
|
51
|
+
*/
|
|
52
|
+
function sendSSEComment(res: ServerResponse, comment: string): void {
|
|
53
|
+
if (res.writableEnded) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
res.write(`: ${comment}\n\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Handle SSE subscription request
|
|
61
|
+
*/
|
|
62
|
+
export async function handleEventSubscription(
|
|
63
|
+
req: IncomingMessage,
|
|
64
|
+
res: ServerResponse,
|
|
65
|
+
eventSubscriber: EventSubscriber
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const clientId = `sse_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
68
|
+
const params = parseQueryParams(req.url ?? '');
|
|
69
|
+
|
|
70
|
+
// Parse filter parameters
|
|
71
|
+
const domain = params.get('domain') ?? undefined;
|
|
72
|
+
const entityId = params.get('entity_id') ?? undefined;
|
|
73
|
+
const eventTypesParam = params.get('event_types');
|
|
74
|
+
const eventTypes = eventTypesParam ? eventTypesParam.split(',').map(t => t.trim()) : undefined;
|
|
75
|
+
const token = params.get('token');
|
|
76
|
+
|
|
77
|
+
logger.info('New SSE subscription request', {
|
|
78
|
+
clientId,
|
|
79
|
+
domain,
|
|
80
|
+
entityId,
|
|
81
|
+
eventTypes,
|
|
82
|
+
hasToken: !!token,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Set SSE headers
|
|
86
|
+
res.writeHead(200, {
|
|
87
|
+
'Content-Type': 'text/event-stream',
|
|
88
|
+
'Cache-Control': 'no-cache',
|
|
89
|
+
'Connection': 'keep-alive',
|
|
90
|
+
'Access-Control-Allow-Origin': req.headers.origin ?? '*',
|
|
91
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
92
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Create client record
|
|
96
|
+
const client: SSEClient = {
|
|
97
|
+
id: clientId,
|
|
98
|
+
res,
|
|
99
|
+
subscriptionIds: [],
|
|
100
|
+
domain,
|
|
101
|
+
entityId,
|
|
102
|
+
eventTypes,
|
|
103
|
+
createdAt: new Date(),
|
|
104
|
+
eventCount: 0,
|
|
105
|
+
};
|
|
106
|
+
clients.set(clientId, client);
|
|
107
|
+
|
|
108
|
+
// Send initial connection event
|
|
109
|
+
sendSSEEvent(res, 'connected', {
|
|
110
|
+
client_id: clientId,
|
|
111
|
+
message: 'Connected to Home Assistant event stream',
|
|
112
|
+
filters: { domain, entityId, eventTypes },
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Ensure event subscriber is connected
|
|
117
|
+
if (!eventSubscriber.isConnected()) {
|
|
118
|
+
try {
|
|
119
|
+
await eventSubscriber.connect();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error('Failed to connect event subscriber', { error });
|
|
122
|
+
sendSSEEvent(res, 'error', {
|
|
123
|
+
error: 'Failed to connect to Home Assistant',
|
|
124
|
+
message: error instanceof Error ? error.message : String(error),
|
|
125
|
+
});
|
|
126
|
+
res.end();
|
|
127
|
+
clients.delete(clientId);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Create event callback
|
|
133
|
+
const eventCallback = (event: HaEvent) => {
|
|
134
|
+
// Apply filters
|
|
135
|
+
if (eventTypes && !eventTypes.includes(event.event_type)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (domain && event.event_type === 'state_changed') {
|
|
140
|
+
const eventDomain = event.data.entity_id?.split('.')[0];
|
|
141
|
+
if (eventDomain !== domain) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (entityId && event.data.entity_id !== entityId) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Update client stats
|
|
151
|
+
client.lastEventAt = new Date();
|
|
152
|
+
client.eventCount++;
|
|
153
|
+
|
|
154
|
+
// Send event to client
|
|
155
|
+
sendSSEEvent(res, event.event_type, {
|
|
156
|
+
event_type: event.event_type,
|
|
157
|
+
entity_id: event.data.entity_id,
|
|
158
|
+
old_state: event.data.old_state,
|
|
159
|
+
new_state: event.data.new_state,
|
|
160
|
+
domain: event.data.domain,
|
|
161
|
+
service: event.data.service,
|
|
162
|
+
service_data: event.data.service_data,
|
|
163
|
+
time_fired: event.time_fired,
|
|
164
|
+
context: event.context,
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Subscribe to events based on filters
|
|
169
|
+
try {
|
|
170
|
+
let subscriptionId: string;
|
|
171
|
+
|
|
172
|
+
if (entityId) {
|
|
173
|
+
// Subscribe to specific entity
|
|
174
|
+
subscriptionId = await eventSubscriber.subscribeEntity(entityId, eventCallback);
|
|
175
|
+
} else if (domain) {
|
|
176
|
+
// Subscribe to domain
|
|
177
|
+
subscriptionId = await eventSubscriber.subscribeDomain(domain, eventCallback);
|
|
178
|
+
} else if (eventTypes && eventTypes.length === 1) {
|
|
179
|
+
// Subscribe to specific event type
|
|
180
|
+
subscriptionId = await eventSubscriber.subscribeEventType(eventTypes[0], eventCallback);
|
|
181
|
+
} else {
|
|
182
|
+
// Subscribe to all state_changed events by default
|
|
183
|
+
subscriptionId = await eventSubscriber.subscribeEventType('state_changed', eventCallback);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
client.subscriptionIds.push(subscriptionId);
|
|
187
|
+
|
|
188
|
+
logger.info('SSE client subscribed to events', {
|
|
189
|
+
clientId,
|
|
190
|
+
subscriptionId,
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
logger.error('Failed to subscribe to events', { error, clientId });
|
|
194
|
+
sendSSEEvent(res, 'error', {
|
|
195
|
+
error: 'Failed to subscribe to events',
|
|
196
|
+
message: error instanceof Error ? error.message : String(error),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Keep-alive ping every 30 seconds
|
|
201
|
+
const keepAliveInterval = setInterval(() => {
|
|
202
|
+
if (res.writableEnded) {
|
|
203
|
+
clearInterval(keepAliveInterval);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
sendSSEComment(res, `keepalive ${new Date().toISOString()}`);
|
|
207
|
+
}, 30000);
|
|
208
|
+
|
|
209
|
+
// Handle client disconnect
|
|
210
|
+
req.on('close', () => {
|
|
211
|
+
clearInterval(keepAliveInterval);
|
|
212
|
+
|
|
213
|
+
// Unsubscribe from all events
|
|
214
|
+
for (const subId of client.subscriptionIds) {
|
|
215
|
+
eventSubscriber.unsubscribe(subId).catch(error => {
|
|
216
|
+
logger.error('Failed to unsubscribe', { error, subscriptionId: subId });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
clients.delete(clientId);
|
|
221
|
+
|
|
222
|
+
logger.info('SSE client disconnected', {
|
|
223
|
+
clientId,
|
|
224
|
+
eventCount: client.eventCount,
|
|
225
|
+
duration: Date.now() - client.createdAt.getTime(),
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Handle errors
|
|
230
|
+
req.on('error', (error) => {
|
|
231
|
+
logger.error('SSE request error', { error, clientId });
|
|
232
|
+
clearInterval(keepAliveInterval);
|
|
233
|
+
clients.delete(clientId);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get list of active SSE clients
|
|
239
|
+
*/
|
|
240
|
+
export function getActiveClients(): Array<{
|
|
241
|
+
id: string;
|
|
242
|
+
domain?: string;
|
|
243
|
+
entityId?: string;
|
|
244
|
+
eventTypes?: string[];
|
|
245
|
+
createdAt: Date;
|
|
246
|
+
lastEventAt?: Date;
|
|
247
|
+
eventCount: number;
|
|
248
|
+
}> {
|
|
249
|
+
return Array.from(clients.values()).map(client => ({
|
|
250
|
+
id: client.id,
|
|
251
|
+
domain: client.domain,
|
|
252
|
+
entityId: client.entityId,
|
|
253
|
+
eventTypes: client.eventTypes,
|
|
254
|
+
createdAt: client.createdAt,
|
|
255
|
+
lastEventAt: client.lastEventAt,
|
|
256
|
+
eventCount: client.eventCount,
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get count of active SSE clients
|
|
262
|
+
*/
|
|
263
|
+
export function getClientCount(): number {
|
|
264
|
+
return clients.size;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Broadcast event to all matching clients
|
|
269
|
+
*/
|
|
270
|
+
export function broadcastEvent(event: string, data: unknown, filter?: {
|
|
271
|
+
domain?: string;
|
|
272
|
+
entityId?: string;
|
|
273
|
+
}): void {
|
|
274
|
+
for (const client of clients.values()) {
|
|
275
|
+
// Apply filter
|
|
276
|
+
if (filter?.domain && client.domain !== filter.domain) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (filter?.entityId && client.entityId !== filter.entityId) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
sendSSEEvent(client.res, event, data);
|
|
284
|
+
client.eventCount++;
|
|
285
|
+
client.lastEventAt = new Date();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Disconnect a specific client
|
|
291
|
+
*/
|
|
292
|
+
export function disconnectClient(clientId: string): boolean {
|
|
293
|
+
const client = clients.get(clientId);
|
|
294
|
+
if (!client) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
sendSSEEvent(client.res, 'disconnect', { reason: 'Server initiated disconnect' });
|
|
299
|
+
client.res.end();
|
|
300
|
+
clients.delete(clientId);
|
|
301
|
+
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Disconnect all clients
|
|
307
|
+
*/
|
|
308
|
+
export function disconnectAllClients(): number {
|
|
309
|
+
const count = clients.size;
|
|
310
|
+
|
|
311
|
+
for (const client of clients.values()) {
|
|
312
|
+
sendSSEEvent(client.res, 'disconnect', { reason: 'Server shutdown' });
|
|
313
|
+
client.res.end();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
clients.clear();
|
|
317
|
+
return count;
|
|
318
|
+
}
|