@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,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP transport tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import type { HaClient } from '../../src/haClient/index.js';
|
|
7
|
+
import type { EnvironmentConfig } from '../../src/config.js';
|
|
8
|
+
|
|
9
|
+
// Mock dependencies
|
|
10
|
+
vi.mock('@modelcontextprotocol/sdk/server/streamable.js');
|
|
11
|
+
vi.mock('../../src/server/common.js');
|
|
12
|
+
vi.mock('../../src/utils/logger.js');
|
|
13
|
+
|
|
14
|
+
describe('Stream Transport', () => {
|
|
15
|
+
let mockClient: HaClient;
|
|
16
|
+
let mockConfig: EnvironmentConfig;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
|
|
21
|
+
mockClient = {
|
|
22
|
+
checkApi: vi.fn().mockResolvedValue({ message: 'API running.' }),
|
|
23
|
+
getStates: vi.fn(),
|
|
24
|
+
getState: vi.fn(),
|
|
25
|
+
callService: vi.fn(),
|
|
26
|
+
getEntitiesByDomain: vi.fn(),
|
|
27
|
+
searchEntities: vi.fn(),
|
|
28
|
+
} as any;
|
|
29
|
+
|
|
30
|
+
mockConfig = {
|
|
31
|
+
baseUrl: 'http://homeassistant.10.0.0.19.nip.io:8123',
|
|
32
|
+
token: 'test-token',
|
|
33
|
+
strictSsl: false,
|
|
34
|
+
timeout: 30000,
|
|
35
|
+
aiProvider: 'ollama',
|
|
36
|
+
aiUrl: 'http://ollama.10.0.0.17.nip.io:11434',
|
|
37
|
+
aiModel: 'qwen3:14b',
|
|
38
|
+
aiTimeout: 60000,
|
|
39
|
+
logLevel: 'info',
|
|
40
|
+
logFormat: 'plain',
|
|
41
|
+
useHttp: true,
|
|
42
|
+
stateful: false,
|
|
43
|
+
httpPort: 3000,
|
|
44
|
+
httpTransport: 'stream',
|
|
45
|
+
httpBindAddr: '127.0.0.1',
|
|
46
|
+
httpPath: '/mcp',
|
|
47
|
+
httpEnableHealthcheck: true,
|
|
48
|
+
httpHealthcheckPath: '/health',
|
|
49
|
+
httpAllowCors: true,
|
|
50
|
+
httpAllowedOrigins: ['127.0.0.1', 'localhost'],
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('Stream Connection', () => {
|
|
55
|
+
it('should create streamable HTTP transport', () => {
|
|
56
|
+
expect(mockConfig.httpTransport).toBe('stream');
|
|
57
|
+
expect(mockConfig.httpPath).toBe('/mcp');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should support stateless mode by default', () => {
|
|
61
|
+
expect(mockConfig.stateful).toBe(false);
|
|
62
|
+
// Each request creates a new transport
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should support stateful mode when enabled', () => {
|
|
66
|
+
const statefulConfig = { ...mockConfig, stateful: true };
|
|
67
|
+
|
|
68
|
+
expect(statefulConfig.stateful).toBe(true);
|
|
69
|
+
// Reuses existing transport for session
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should use modern MCP protocol version', () => {
|
|
73
|
+
const protocolVersion = '2025-03-26';
|
|
74
|
+
|
|
75
|
+
expect(protocolVersion).toBe('2025-03-26');
|
|
76
|
+
// This is the modern streamable HTTP protocol version
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('Request Handling', () => {
|
|
81
|
+
it('should handle GET requests', () => {
|
|
82
|
+
const method = 'GET';
|
|
83
|
+
|
|
84
|
+
expect(method).toBe('GET');
|
|
85
|
+
// Stream transport supports GET for initial connection
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle POST requests', () => {
|
|
89
|
+
const method = 'POST';
|
|
90
|
+
|
|
91
|
+
expect(method).toBe('POST');
|
|
92
|
+
// Stream transport supports POST for messages
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should parse request body for non-GET requests', () => {
|
|
96
|
+
const method: string = 'POST';
|
|
97
|
+
const shouldParseBody = method !== 'GET';
|
|
98
|
+
|
|
99
|
+
expect(shouldParseBody).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should not parse body for GET requests', () => {
|
|
103
|
+
const method: string = 'GET';
|
|
104
|
+
const shouldParseBody = method !== 'GET';
|
|
105
|
+
|
|
106
|
+
expect(shouldParseBody).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('Session Management', () => {
|
|
111
|
+
it('should create new transport for stateless mode', () => {
|
|
112
|
+
expect(mockConfig.stateful).toBe(false);
|
|
113
|
+
|
|
114
|
+
const existingTransport = undefined;
|
|
115
|
+
const shouldCreateNew = !existingTransport;
|
|
116
|
+
|
|
117
|
+
expect(shouldCreateNew).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should reuse transport for stateful mode', () => {
|
|
121
|
+
const statefulConfig = { ...mockConfig, stateful: true };
|
|
122
|
+
const existingTransport = {
|
|
123
|
+
transport: {},
|
|
124
|
+
server: {},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
expect(statefulConfig.stateful).toBe(true);
|
|
128
|
+
expect(existingTransport).toBeDefined();
|
|
129
|
+
// Should use existingTransport
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should read session ID from header', () => {
|
|
133
|
+
const headers = {
|
|
134
|
+
'mcp-session-id': 'session-123',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const sessionId = headers['mcp-session-id'];
|
|
138
|
+
|
|
139
|
+
expect(sessionId).toBe('session-123');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle missing session ID', () => {
|
|
143
|
+
const headers = {};
|
|
144
|
+
const sessionId = headers['mcp-session-id' as keyof typeof headers];
|
|
145
|
+
|
|
146
|
+
expect(sessionId).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Transport Lifecycle', () => {
|
|
151
|
+
it('should connect server on first use', () => {
|
|
152
|
+
const existingTransport = undefined;
|
|
153
|
+
const isFirstConnection = !existingTransport;
|
|
154
|
+
|
|
155
|
+
expect(isFirstConnection).toBe(true);
|
|
156
|
+
// Should call server.connect(transport)
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should skip connection for existing transport', () => {
|
|
160
|
+
const existingTransport = {
|
|
161
|
+
transport: {},
|
|
162
|
+
server: {},
|
|
163
|
+
};
|
|
164
|
+
const isFirstConnection = !existingTransport;
|
|
165
|
+
|
|
166
|
+
expect(isFirstConnection).toBe(false);
|
|
167
|
+
// Should not call server.connect again
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle request through transport', () => {
|
|
171
|
+
const request = { method: 'POST', url: '/mcp' };
|
|
172
|
+
const response = {};
|
|
173
|
+
const body = { method: 'tools/call' };
|
|
174
|
+
|
|
175
|
+
expect(request.method).toBe('POST');
|
|
176
|
+
// Should call transport.handleRequest(req, res, body)
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('Stateful vs Stateless', () => {
|
|
181
|
+
it('should return transport state in stateful mode', () => {
|
|
182
|
+
const statefulConfig = { ...mockConfig, stateful: true };
|
|
183
|
+
|
|
184
|
+
expect(statefulConfig.stateful).toBe(true);
|
|
185
|
+
// handleStreamRequest should return state
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should not return state in stateless mode', () => {
|
|
189
|
+
expect(mockConfig.stateful).toBe(false);
|
|
190
|
+
// handleStreamRequest should return void
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should store state in sessions map for stateful mode', () => {
|
|
194
|
+
const sessions = new Map();
|
|
195
|
+
const sessionId = 'session-123';
|
|
196
|
+
const state = {
|
|
197
|
+
transport: {},
|
|
198
|
+
server: {},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
sessions.set(sessionId, state);
|
|
202
|
+
|
|
203
|
+
expect(sessions.get(sessionId)).toBe(state);
|
|
204
|
+
expect(sessions.size).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('Error Handling', () => {
|
|
209
|
+
it('should handle transport creation errors', () => {
|
|
210
|
+
const error = new Error('Failed to create transport');
|
|
211
|
+
|
|
212
|
+
expect(error.message).toBe('Failed to create transport');
|
|
213
|
+
// Should propagate error
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should handle request handling errors', () => {
|
|
217
|
+
const error = new Error('Failed to handle request');
|
|
218
|
+
|
|
219
|
+
expect(error.message).toBe('Failed to handle request');
|
|
220
|
+
// Should propagate error
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should handle server connection errors', () => {
|
|
224
|
+
const error = new Error('Failed to connect server');
|
|
225
|
+
|
|
226
|
+
expect(error.message).toBe('Failed to connect server');
|
|
227
|
+
// Should propagate error
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('Transport State Type', () => {
|
|
232
|
+
it('should have transport and server properties', () => {
|
|
233
|
+
const state = {
|
|
234
|
+
transport: { handleRequest: vi.fn() },
|
|
235
|
+
server: { connect: vi.fn() },
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
expect(state.transport).toBeDefined();
|
|
239
|
+
expect(state.server).toBeDefined();
|
|
240
|
+
expect(typeof state.transport.handleRequest).toBe('function');
|
|
241
|
+
expect(typeof state.server.connect).toBe('function');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should distinguish from SSE transport state', () => {
|
|
245
|
+
const streamState = {
|
|
246
|
+
transport: { handleRequest: vi.fn() },
|
|
247
|
+
server: {},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const sseState = {
|
|
251
|
+
transport: { sessionId: 'sse-123' },
|
|
252
|
+
server: {},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
expect('handleRequest' in streamState.transport).toBe(true);
|
|
256
|
+
expect('sessionId' in sseState.transport).toBe(true);
|
|
257
|
+
expect('sessionId' in streamState.transport).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('Protocol Differences', () => {
|
|
262
|
+
it('should not use sessionId in transport like SSE does', () => {
|
|
263
|
+
const streamTransport = {
|
|
264
|
+
handleRequest: vi.fn(),
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
expect('sessionId' in streamTransport).toBe(false);
|
|
268
|
+
// Stream transport doesn't embed sessionId in transport object
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should use HTTP headers for session tracking', () => {
|
|
272
|
+
const sessionHeader = 'Mcp-Session-Id';
|
|
273
|
+
|
|
274
|
+
expect(sessionHeader).toBe('Mcp-Session-Id');
|
|
275
|
+
// Stream uses headers, not transport properties
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should support both GET and POST unlike SSE', () => {
|
|
279
|
+
const supportedMethods = ['GET', 'POST'];
|
|
280
|
+
|
|
281
|
+
expect(supportedMethods).toContain('GET');
|
|
282
|
+
expect(supportedMethods).toContain('POST');
|
|
283
|
+
// SSE uses GET for connection, POST for messages
|
|
284
|
+
// Stream uses both for the same endpoint
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { registerAllTools } from '../../src/tools/registry.js';
|
|
8
|
+
import { HaClient } from '../../src/haClient/index.js';
|
|
9
|
+
import type { EnvironmentConfig } from '../../src/config.js';
|
|
10
|
+
import {
|
|
11
|
+
CallToolRequestSchema,
|
|
12
|
+
ListToolsRequestSchema,
|
|
13
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
14
|
+
|
|
15
|
+
describe('Tool Registry', () => {
|
|
16
|
+
let server: Server;
|
|
17
|
+
let mockClient: HaClient;
|
|
18
|
+
let mockConfig: EnvironmentConfig;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockConfig = {
|
|
22
|
+
baseUrl: 'http://homeassistant.10.0.0.19.nip.io:8123',
|
|
23
|
+
token: 'test-token',
|
|
24
|
+
strictSsl: false,
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
aiProvider: 'ollama',
|
|
27
|
+
aiUrl: 'http://ollama.10.0.0.17.nip.io:11434',
|
|
28
|
+
aiModel: 'qwen3:14b',
|
|
29
|
+
aiTimeout: 60000,
|
|
30
|
+
logLevel: 'error', // Suppress logs during tests
|
|
31
|
+
logFormat: 'plain',
|
|
32
|
+
useHttp: false,
|
|
33
|
+
stateful: false,
|
|
34
|
+
httpTransport: 'stream',
|
|
35
|
+
httpEnableHealthcheck: true,
|
|
36
|
+
httpAllowCors: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
mockClient = new HaClient(mockConfig);
|
|
40
|
+
|
|
41
|
+
server = new Server(
|
|
42
|
+
{
|
|
43
|
+
name: 'test-server',
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
capabilities: {
|
|
48
|
+
tools: {},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('registerAllTools', () => {
|
|
55
|
+
it('should register all tools without errors', () => {
|
|
56
|
+
expect(() => registerAllTools(server, mockClient)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should register ListTools handler', () => {
|
|
60
|
+
registerAllTools(server, mockClient);
|
|
61
|
+
|
|
62
|
+
// The server should have handlers registered
|
|
63
|
+
// We can't directly test private handlers, but we can verify no errors
|
|
64
|
+
expect(server).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should register CallTool handler', () => {
|
|
68
|
+
registerAllTools(server, mockClient);
|
|
69
|
+
|
|
70
|
+
// The server should have handlers registered
|
|
71
|
+
expect(server).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('Tool List', () => {
|
|
76
|
+
it('should include all 5 tools', () => {
|
|
77
|
+
const expectedTools = [
|
|
78
|
+
'getStates',
|
|
79
|
+
'getState',
|
|
80
|
+
'callService',
|
|
81
|
+
'getEntitiesByDomain',
|
|
82
|
+
'searchEntities',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
expect(expectedTools).toHaveLength(5);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should have correct tool names', () => {
|
|
89
|
+
const toolNames = [
|
|
90
|
+
'getStates',
|
|
91
|
+
'getState',
|
|
92
|
+
'callService',
|
|
93
|
+
'getEntitiesByDomain',
|
|
94
|
+
'searchEntities',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
expect(toolNames).toContain('getStates');
|
|
98
|
+
expect(toolNames).toContain('getState');
|
|
99
|
+
expect(toolNames).toContain('callService');
|
|
100
|
+
expect(toolNames).toContain('getEntitiesByDomain');
|
|
101
|
+
expect(toolNames).toContain('searchEntities');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Tool Schemas', () => {
|
|
106
|
+
it('getStates should have no required parameters', () => {
|
|
107
|
+
const schema = {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {},
|
|
110
|
+
required: [],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
expect(schema.required).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('getState should require entity_id', () => {
|
|
117
|
+
const schema = {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
entity_id: { type: 'string', description: 'Entity ID' },
|
|
121
|
+
},
|
|
122
|
+
required: ['entity_id'],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
expect(schema.required).toContain('entity_id');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('callService should require domain and service', () => {
|
|
129
|
+
const schema = {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
domain: { type: 'string' },
|
|
133
|
+
service: { type: 'string' },
|
|
134
|
+
target: { type: 'object' },
|
|
135
|
+
data: { type: 'object' },
|
|
136
|
+
},
|
|
137
|
+
required: ['domain', 'service'],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
expect(schema.required).toContain('domain');
|
|
141
|
+
expect(schema.required).toContain('service');
|
|
142
|
+
expect(schema.required).not.toContain('target');
|
|
143
|
+
expect(schema.required).not.toContain('data');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('getEntitiesByDomain should require domain', () => {
|
|
147
|
+
const schema = {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
domain: { type: 'string' },
|
|
151
|
+
},
|
|
152
|
+
required: ['domain'],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(schema.required).toContain('domain');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('searchEntities should require query', () => {
|
|
159
|
+
const schema = {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
query: { type: 'string' },
|
|
163
|
+
},
|
|
164
|
+
required: ['query'],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
expect(schema.required).toContain('query');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('Tool Descriptions', () => {
|
|
172
|
+
it('should have descriptive names for all tools', () => {
|
|
173
|
+
const toolDescriptions = {
|
|
174
|
+
getStates: 'Get all entity states from Home Assistant',
|
|
175
|
+
getState: 'Get the state of a specific entity',
|
|
176
|
+
callService: 'Call a Home Assistant service',
|
|
177
|
+
getEntitiesByDomain: 'Get all entities for a specific domain',
|
|
178
|
+
searchEntities: 'Search for entities by name or entity_id',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
expect(toolDescriptions.getStates).toBeDefined();
|
|
182
|
+
expect(toolDescriptions.getState).toBeDefined();
|
|
183
|
+
expect(toolDescriptions.callService).toBeDefined();
|
|
184
|
+
expect(toolDescriptions.getEntitiesByDomain).toBeDefined();
|
|
185
|
+
expect(toolDescriptions.searchEntities).toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('Error Handling', () => {
|
|
190
|
+
it('should throw error for unknown tool', () => {
|
|
191
|
+
const unknownTool = 'unknownTool';
|
|
192
|
+
const validTools = [
|
|
193
|
+
'getStates',
|
|
194
|
+
'getState',
|
|
195
|
+
'callService',
|
|
196
|
+
'getEntitiesByDomain',
|
|
197
|
+
'searchEntities',
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
expect(validTools).not.toContain(unknownTool);
|
|
201
|
+
// Should throw 'Unknown tool' error
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should validate required parameters', () => {
|
|
205
|
+
// getState without entity_id should fail
|
|
206
|
+
const args = {};
|
|
207
|
+
const hasEntityId = 'entity_id' in args;
|
|
208
|
+
|
|
209
|
+
expect(hasEntityId).toBe(false);
|
|
210
|
+
// Should throw validation error
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle client errors gracefully', async () => {
|
|
214
|
+
const error = new Error('API error');
|
|
215
|
+
|
|
216
|
+
// When client throws, should be caught and returned as error
|
|
217
|
+
expect(error).toBeInstanceOf(Error);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('Tool Execution', () => {
|
|
222
|
+
it('should call client.getStates for getStates tool', async () => {
|
|
223
|
+
const spy = vi.spyOn(mockClient, 'getStates').mockResolvedValue([]);
|
|
224
|
+
|
|
225
|
+
registerAllTools(server, mockClient);
|
|
226
|
+
|
|
227
|
+
// Verify the spy was set up
|
|
228
|
+
expect(spy).toBeDefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should call client.getState for getState tool', async () => {
|
|
232
|
+
const spy = vi.spyOn(mockClient, 'getState').mockResolvedValue(null);
|
|
233
|
+
|
|
234
|
+
registerAllTools(server, mockClient);
|
|
235
|
+
|
|
236
|
+
expect(spy).toBeDefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should call client.callService for callService tool', async () => {
|
|
240
|
+
const spy = vi.spyOn(mockClient, 'callService').mockResolvedValue({
|
|
241
|
+
context: { id: 'test', parent_id: null, user_id: null },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
registerAllTools(server, mockClient);
|
|
245
|
+
|
|
246
|
+
expect(spy).toBeDefined();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should call client.getEntitiesByDomain for getEntitiesByDomain tool', async () => {
|
|
250
|
+
const spy = vi.spyOn(mockClient, 'getEntitiesByDomain').mockResolvedValue([]);
|
|
251
|
+
|
|
252
|
+
registerAllTools(server, mockClient);
|
|
253
|
+
|
|
254
|
+
expect(spy).toBeDefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should call client.searchEntities for searchEntities tool', async () => {
|
|
258
|
+
const spy = vi.spyOn(mockClient, 'searchEntities').mockResolvedValue([]);
|
|
259
|
+
|
|
260
|
+
registerAllTools(server, mockClient);
|
|
261
|
+
|
|
262
|
+
expect(spy).toBeDefined();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('Centralized Registration', () => {
|
|
267
|
+
it('should use single ListTools handler', () => {
|
|
268
|
+
registerAllTools(server, mockClient);
|
|
269
|
+
|
|
270
|
+
// Only one handler should be registered for ListToolsRequestSchema
|
|
271
|
+
// Multiple calls to setRequestHandler would overwrite previous handlers
|
|
272
|
+
expect(server).toBeDefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should use single CallTool handler with switch statement', () => {
|
|
276
|
+
registerAllTools(server, mockClient);
|
|
277
|
+
|
|
278
|
+
// Only one handler for CallToolRequestSchema
|
|
279
|
+
// Uses switch statement to dispatch to correct tool
|
|
280
|
+
expect(server).toBeDefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should prevent handler overwriting', () => {
|
|
284
|
+
// This was the bug that was fixed: multiple setRequestHandler calls
|
|
285
|
+
// would overwrite each other, causing only the last tool to work
|
|
286
|
+
registerAllTools(server, mockClient);
|
|
287
|
+
|
|
288
|
+
// All tools should be accessible through the single handler
|
|
289
|
+
expect(server).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('Tool Response Format', () => {
|
|
294
|
+
it('should return array of entities for getStates', () => {
|
|
295
|
+
const response = {
|
|
296
|
+
content: [
|
|
297
|
+
{
|
|
298
|
+
type: 'text',
|
|
299
|
+
text: JSON.stringify([]),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
expect(response.content[0].type).toBe('text');
|
|
305
|
+
expect(Array.isArray(JSON.parse(response.content[0].text))).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should return single entity for getState', () => {
|
|
309
|
+
const entity = {
|
|
310
|
+
entity_id: 'light.living_room',
|
|
311
|
+
state: 'on',
|
|
312
|
+
attributes: {},
|
|
313
|
+
last_changed: '',
|
|
314
|
+
last_updated: '',
|
|
315
|
+
context: { id: '', parent_id: null, user_id: null },
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const response = {
|
|
319
|
+
content: [
|
|
320
|
+
{
|
|
321
|
+
type: 'text',
|
|
322
|
+
text: JSON.stringify(entity),
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
expect(response.content[0].type).toBe('text');
|
|
328
|
+
const parsed = JSON.parse(response.content[0].text);
|
|
329
|
+
expect(parsed.entity_id).toBe('light.living_room');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should return service call result for callService', () => {
|
|
333
|
+
const result = {
|
|
334
|
+
context: {
|
|
335
|
+
id: 'context-123',
|
|
336
|
+
parent_id: null,
|
|
337
|
+
user_id: 'user-456',
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const response = {
|
|
342
|
+
content: [
|
|
343
|
+
{
|
|
344
|
+
type: 'text',
|
|
345
|
+
text: JSON.stringify(result),
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
expect(response.content[0].type).toBe('text');
|
|
351
|
+
const parsed = JSON.parse(response.content[0].text);
|
|
352
|
+
expect(parsed.context.id).toBe('context-123');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|