@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. package/vitest.config.ts +0 -38
@@ -1,111 +0,0 @@
1
- /**
2
- * Regression: tools/list over the REAL HTTP path is filtered by the caller's token.
3
- *
4
- * Drives the production pieces end to end — bearer auth middleware (real HS256
5
- * JWTs) -> parseBody -> handleStreamRequest -> per-request McpServer — so that
6
- * removing the request-to-createServer permission forwarding in stream.ts fails
7
- * here, not just the createServer-level unit tests.
8
- */
9
-
10
- import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
11
- import { createServer as createHttpServer, type Server } from 'node:http';
12
- import type { AddressInfo } from 'node:net';
13
- import { createAuthMiddleware } from '../../src/server/auth.js';
14
- import { parseBody } from '../../src/server/http.js';
15
- import { handleStreamRequest } from '../../src/server/stream.js';
16
- import { createJwt } from '../../src/utils/jwt.js';
17
- import { parsePermissionsConfig } from '../../src/permissions/index.js';
18
- import type { EnvironmentConfig } from '../../src/config.js';
19
-
20
- vi.mock('../../src/utils/logger.js', () => ({
21
- logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
- }));
23
-
24
- const SECRET = 'test-secret-for-tool-visibility-0123456789';
25
- const permissions = parsePermissionsConfig(
26
- JSON.stringify({
27
- users: [
28
- { sub: 'admin', role: 'admin' },
29
- { sub: 'operator', role: 'operator' },
30
- { sub: 'reader', role: 'readonly' },
31
- ],
32
- defaultRole: 'NONE',
33
- })
34
- );
35
-
36
- // Only the fields the stream path reads; HA client is a registration-only stub.
37
- const config = { stateful: false, httpAllowedHosts: [] } as unknown as EnvironmentConfig;
38
- const options = { haClient: {} as never, config };
39
-
40
- let server: Server;
41
- let baseUrl: string;
42
-
43
- beforeAll(async () => {
44
- const auth = createAuthMiddleware({ method: 'bearer', secret: SECRET, permissions });
45
- server = createHttpServer(async (req, res) => {
46
- if (!auth(req, res)) return;
47
- const body = await parseBody(req);
48
- await handleStreamRequest(options, req, res, body);
49
- });
50
- await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
51
- baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/mcp`;
52
- });
53
-
54
- afterAll(async () => {
55
- await new Promise<void>((resolve) => server.close(() => resolve()));
56
- });
57
-
58
- async function listTools(sub: string): Promise<string[]> {
59
- const token = createJwt({ sub, exp: Math.floor(Date.now() / 1000) + 300 }, SECRET);
60
- const res = await fetch(baseUrl, {
61
- method: 'POST',
62
- headers: {
63
- Authorization: `Bearer ${token}`,
64
- 'Content-Type': 'application/json',
65
- Accept: 'application/json, text/event-stream',
66
- },
67
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
68
- });
69
- expect(res.status).toBe(200);
70
- const text = await res.text();
71
- const json = text.includes('data:')
72
- ? JSON.parse(text.split('\n').find((l) => l.startsWith('data:'))!.slice(5))
73
- : JSON.parse(text);
74
- return (json.result.tools as { name: string }[]).map((t) => t.name);
75
- }
76
-
77
- describe('tools/list over HTTP is filtered by the bearer token', () => {
78
- it('admin sees admin and control tools', async () => {
79
- const tools = await listTools('admin');
80
- expect(tools).toContain('restartHomeAssistant');
81
- expect(tools).toContain('controlLight');
82
- });
83
-
84
- it('a READONLY token is not offered control or admin tools', async () => {
85
- const tools = await listTools('reader');
86
- expect(tools).toContain('getState');
87
- expect(tools).not.toContain('controlLight');
88
- expect(tools).not.toContain('restartHomeAssistant');
89
- expect(tools).not.toContain('deleteAutomation');
90
- });
91
-
92
- it('an unmapped sub (defaultRole NONE) is offered no permission-gated tools', async () => {
93
- const tools = await listTools('nobody');
94
- expect(tools).not.toContain('getState');
95
- expect(tools).not.toContain('controlLight');
96
- });
97
-
98
- it('interleaved callers each get their own filtered list (no shared server instance)', async () => {
99
- const [reader1, admin, reader2, operator] = await Promise.all([
100
- listTools('reader'),
101
- listTools('admin'),
102
- listTools('reader'),
103
- listTools('operator'),
104
- ]);
105
- expect(reader1).toEqual(reader2);
106
- expect(reader1).not.toContain('controlLight');
107
- expect(admin).toContain('restartHomeAssistant');
108
- expect(operator).toContain('controlLight');
109
- expect(operator).not.toContain('restartHomeAssistant');
110
- });
111
- });
@@ -1,129 +0,0 @@
1
- /**
2
- * tools/list is filtered by the caller's permission mask.
3
- *
4
- * A caller is only offered tools it can actually use, so an LLM client does not
5
- * plan around (or learn about) admin tools it would be denied. Execution-time
6
- * RBAC in wrapToolHandler is unchanged and still enforced.
7
- */
8
-
9
- import { describe, it, expect } from 'vitest';
10
- import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
11
- import { createServer } from '../../src/server/common.js';
12
- import { Role, Permission } from '../../src/permissions/index.js';
13
- import { getToolRequiredPermission } from '../../src/tools/common.js';
14
- import { canReadAnyResource } from '../../src/tools/omada/graph.js';
15
-
16
- // Registration never calls the HA client; a stub is enough to register HA tools.
17
- const haClient = {} as never;
18
-
19
- async function listToolNames(callerPermissions: number | undefined): Promise<string[]> {
20
- const server = createServer({ haClient, callerPermissions });
21
- const client = new Client({ name: 'visibility-test', version: '0' });
22
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
23
- await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
24
- try {
25
- const { tools } = await client.listTools();
26
- return tools.map((t) => t.name).sort();
27
- } finally {
28
- await client.close();
29
- await server.close();
30
- }
31
- }
32
-
33
- describe('tools/list visibility by caller permissions', () => {
34
- it('lists every tool when no caller mask is given (stdio local trust)', async () => {
35
- const all = await listToolNames(undefined);
36
- expect(all).toContain('restartHomeAssistant');
37
- expect(all).toContain('controlLight');
38
- expect(all).toContain('getState');
39
- });
40
-
41
- it('a full-permission caller sees the same list as unfiltered', async () => {
42
- expect(await listToolNames(0xff)).toEqual(await listToolNames(undefined));
43
- });
44
-
45
- it('a READONLY caller sees only tools it is permitted to call', async () => {
46
- const all = await listToolNames(undefined);
47
- const visible = await listToolNames(Role.READONLY);
48
-
49
- expect(visible).toContain('getState');
50
- expect(visible).not.toContain('controlLight');
51
- expect(visible).not.toContain('restartHomeAssistant');
52
- expect(visible).not.toContain('deleteAutomation');
53
- expect(visible.length).toBeLessThan(all.length);
54
-
55
- // Every hidden tool really is one the caller would be denied, and every
56
- // visible tool with a static bit is one the caller holds.
57
- for (const name of all) {
58
- const required = getToolRequiredPermission(name);
59
- const permitted = required === undefined || (Role.READONLY & required) === required;
60
- expect(visible.includes(name), `${name} visibility`).toBe(permitted);
61
- }
62
- });
63
-
64
- it('an OPERATOR caller sees control tools but not admin/configure tools', async () => {
65
- const visible = await listToolNames(Role.OPERATOR);
66
- expect(visible).toContain('controlLight');
67
- expect(visible).toContain('sendNotification');
68
- expect(visible).not.toContain('restartHomeAssistant');
69
- expect(visible).not.toContain('createAutomation');
70
- });
71
-
72
- it('a caller with no permissions sees no permission-gated tools', async () => {
73
- const visible = await listToolNames(Role.NONE);
74
- for (const name of visible) {
75
- expect(getToolRequiredPermission(name), `${name} should not be listed for NONE`).toBeUndefined();
76
- }
77
- });
78
-
79
- it('a hidden tool cannot be called either', async () => {
80
- const server = createServer({ haClient, callerPermissions: Permission.QUERY });
81
- const client = new Client({ name: 'visibility-test', version: '0' });
82
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
83
- await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
84
- try {
85
- const outcome = await client
86
- .callTool({ name: 'restartHomeAssistant', arguments: {} })
87
- .then((r) => (r.isError ? 'error-result' : 'success'), () => 'rejected');
88
- expect(outcome).not.toBe('success');
89
- } finally {
90
- await client.close();
91
- await server.close();
92
- }
93
- });
94
- });
95
-
96
- describe('omada_read visibility (per-path RBAC tool)', () => {
97
- // Graph mode registers omada_browse + omada_read; registration never calls the client.
98
- async function listOmadaTools(callerPermissions: number): Promise<string[]> {
99
- const server = createServer({ omadaClient: {} as never, toolRegistrationMode: 'graph', callerPermissions });
100
- const client = new Client({ name: 'visibility-test', version: '0' });
101
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
102
- await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
103
- try {
104
- const { tools } = await client.listTools();
105
- return tools.map((t) => t.name).filter((n) => n.startsWith('omada_'));
106
- } finally {
107
- await client.close();
108
- await server.close();
109
- }
110
- }
111
-
112
- it('is hidden from a caller who can read no Omada resource (NONE)', async () => {
113
- expect(await listOmadaTools(Role.NONE)).not.toContain('omada_read');
114
- });
115
-
116
- it('is listed for a caller who can read some resources (QUERY)', async () => {
117
- expect(await listOmadaTools(Permission.QUERY)).toContain('omada_read');
118
- });
119
-
120
- it('is listed for an ADMIN-only mask, which can still read the ADMIN-gated /security nodes', async () => {
121
- expect(canReadAnyResource(Permission.ADMIN)).toBe(true);
122
- expect(await listOmadaTools(Permission.ADMIN)).toContain('omada_read');
123
- });
124
-
125
- it('is hidden for a mask that matches no node (e.g. NOTIFY only)', async () => {
126
- expect(canReadAnyResource(Permission.NOTIFY)).toBe(false);
127
- expect(await listOmadaTools(Permission.NOTIFY)).not.toContain('omada_read');
128
- });
129
- });
@@ -1,227 +0,0 @@
1
- /**
2
- * Unit tests for AI analyzeSensors tool handler
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
- import type { McpServer } from '@modelcontextprotocol/server';
7
- import type { LocalAIClient } from '../../../src/localAI/index.js';
8
- import { registerAnalyzeSensorsTool } from '../../../src/tools/ai/analyzeSensors.js';
9
-
10
- // Create mock server that captures registered handlers
11
- function createMockServer() {
12
- const handlers = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
13
- return {
14
- registerTool: vi.fn((name: string, config: unknown, handler: (...args: unknown[]) => unknown) => {
15
- handlers.set(name, { config, handler });
16
- }),
17
- handlers,
18
- } as unknown as McpServer & { handlers: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
19
- }
20
-
21
- // Create mock LocalAIClient
22
- function createMockAIClient() {
23
- return {
24
- analyzeSensors: vi.fn(),
25
- } as unknown as LocalAIClient;
26
- }
27
-
28
- // Mock extra with AI permission
29
- const mockExtra = {
30
- sessionId: 'test-session',
31
- http: { authInfo: { extra: { permissions: 0xff } } }, // All permissions including AI
32
- };
33
-
34
- // Mock extra without AI permission
35
- const noAiPermExtra = {
36
- sessionId: 'test-session',
37
- http: { authInfo: { extra: { permissions: 0x08 } } }, // QUERY only
38
- };
39
-
40
- // Parse JSON result from tool output
41
- function parseResult(result: { content: { text: string }[] }): unknown {
42
- return JSON.parse(result.content[0].text);
43
- }
44
-
45
- describe('AI analyzeSensors Tool Handler', () => {
46
- let server: ReturnType<typeof createMockServer>;
47
-
48
- beforeEach(() => {
49
- server = createMockServer();
50
- vi.clearAllMocks();
51
- });
52
-
53
- describe('Registration', () => {
54
- it('should register the tool', () => {
55
- const aiClient = createMockAIClient();
56
- registerAnalyzeSensorsTool(server, aiClient);
57
-
58
- expect(server.registerTool).toHaveBeenCalledWith(
59
- 'analyzeSensors',
60
- expect.objectContaining({
61
- description: expect.any(String),
62
- }),
63
- expect.any(Function)
64
- );
65
- });
66
-
67
- it('should register with optional AI client', () => {
68
- registerAnalyzeSensorsTool(server, undefined);
69
-
70
- expect(server.registerTool).toHaveBeenCalledWith(
71
- 'analyzeSensors',
72
- expect.objectContaining({
73
- description: expect.any(String),
74
- }),
75
- expect.any(Function)
76
- );
77
- });
78
- });
79
-
80
- describe('AI Client Not Configured', () => {
81
- it('should return error when AI client is not provided', async () => {
82
- registerAnalyzeSensorsTool(server, undefined);
83
- const handler = server.handlers.get('analyzeSensors')!.handler;
84
-
85
- const sensorData = {
86
- 'sensor.temperature': { state: '25', unit: '°C' },
87
- };
88
-
89
- const result = await handler({ sensors: sensorData }, mockExtra);
90
- const parsed = parseResult(result) as { error: string; message: string };
91
-
92
- expect(parsed.error).toBe('AI client not configured');
93
- expect(parsed.message).toContain('AI_URL');
94
- });
95
- });
96
-
97
- describe('Input Validation', () => {
98
- it('should return error when sensors is undefined', async () => {
99
- const aiClient = createMockAIClient();
100
- registerAnalyzeSensorsTool(server, aiClient);
101
- const handler = server.handlers.get('analyzeSensors')!.handler;
102
-
103
- const result = await handler({ sensors: undefined }, mockExtra);
104
- const parsed = parseResult(result) as { error: string };
105
-
106
- expect(parsed.error).toBe('sensors object is required');
107
- expect(aiClient.analyzeSensors).not.toHaveBeenCalled();
108
- });
109
-
110
- it('should return error when sensors is not an object', async () => {
111
- const aiClient = createMockAIClient();
112
- registerAnalyzeSensorsTool(server, aiClient);
113
- const handler = server.handlers.get('analyzeSensors')!.handler;
114
-
115
- const result = await handler({ sensors: 'invalid' }, mockExtra);
116
- const parsed = parseResult(result) as { error: string };
117
-
118
- expect(parsed.error).toBe('sensors object is required');
119
- expect(aiClient.analyzeSensors).not.toHaveBeenCalled();
120
- });
121
- });
122
-
123
- describe('Successful Analysis', () => {
124
- it('should analyze sensors and return result', async () => {
125
- const aiClient = createMockAIClient();
126
- const mockAnalysis = {
127
- summary: 'All sensors are operating normally',
128
- issues: [],
129
- recommendations: ['Consider adding humidity sensors'],
130
- };
131
- (aiClient.analyzeSensors as ReturnType<typeof vi.fn>).mockResolvedValue(mockAnalysis);
132
-
133
- registerAnalyzeSensorsTool(server, aiClient);
134
- const handler = server.handlers.get('analyzeSensors')!.handler;
135
-
136
- const sensorData = {
137
- 'sensor.temperature': { state: '25', unit: '°C', attributes: { friendly_name: 'Living Room' } },
138
- 'sensor.humidity': { state: '50', unit: '%', attributes: { friendly_name: 'Living Room Humidity' } },
139
- };
140
-
141
- const result = await handler({ sensors: sensorData }, mockExtra);
142
- const parsed = parseResult(result);
143
-
144
- expect(parsed).toEqual(mockAnalysis);
145
- expect(aiClient.analyzeSensors).toHaveBeenCalledWith(sensorData);
146
- });
147
-
148
- it('should handle empty sensor object', async () => {
149
- const aiClient = createMockAIClient();
150
- const mockAnalysis = { summary: 'No sensors provided for analysis', issues: [], recommendations: [] };
151
- (aiClient.analyzeSensors as ReturnType<typeof vi.fn>).mockResolvedValue(mockAnalysis);
152
-
153
- registerAnalyzeSensorsTool(server, aiClient);
154
- const handler = server.handlers.get('analyzeSensors')!.handler;
155
-
156
- const result = await handler({ sensors: {} }, mockExtra);
157
- const parsed = parseResult(result);
158
-
159
- expect(parsed).toEqual(mockAnalysis);
160
- expect(aiClient.analyzeSensors).toHaveBeenCalledWith({});
161
- });
162
- });
163
-
164
- describe('AI Client Error Handling', () => {
165
- it('should handle AI client errors gracefully', async () => {
166
- const aiClient = createMockAIClient();
167
- (aiClient.analyzeSensors as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Ollama connection failed'));
168
-
169
- registerAnalyzeSensorsTool(server, aiClient);
170
- const handler = server.handlers.get('analyzeSensors')!.handler;
171
-
172
- const sensorData = { 'sensor.test': { state: '10' } };
173
- const result = await handler({ sensors: sensorData }, mockExtra);
174
- const parsed = parseResult(result) as { error: string; message: string };
175
-
176
- expect(parsed.error).toBe('Failed to execute analyzeSensors');
177
- expect(parsed.message).toContain('Ollama connection failed');
178
- });
179
-
180
- it('should handle timeout errors', async () => {
181
- const aiClient = createMockAIClient();
182
- (aiClient.analyzeSensors as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Request timeout'));
183
-
184
- registerAnalyzeSensorsTool(server, aiClient);
185
- const handler = server.handlers.get('analyzeSensors')!.handler;
186
-
187
- const sensorData = { 'sensor.test': { state: '10' } };
188
- const result = await handler({ sensors: sensorData }, mockExtra);
189
- const parsed = parseResult(result) as { error: string; message: string };
190
-
191
- expect(parsed.error).toBe('Failed to execute analyzeSensors');
192
- expect(parsed.message).toContain('timeout');
193
- });
194
- });
195
-
196
- describe('Permission Enforcement', () => {
197
- it('should deny access without AI permission', async () => {
198
- const aiClient = createMockAIClient();
199
- registerAnalyzeSensorsTool(server, aiClient);
200
- const handler = server.handlers.get('analyzeSensors')!.handler;
201
-
202
- const sensorData = { 'sensor.test': { state: '10' } };
203
- const result = await handler({ sensors: sensorData }, noAiPermExtra);
204
- const parsed = parseResult(result) as { error: string; message: string };
205
-
206
- expect(parsed.error).toBe('Permission denied');
207
- expect(parsed.message).toContain('AI');
208
- expect(aiClient.analyzeSensors).not.toHaveBeenCalled();
209
- });
210
-
211
- it('should allow access with AI permission', async () => {
212
- const aiClient = createMockAIClient();
213
- const mockAnalysis = { summary: 'Analysis complete', issues: [], recommendations: [] };
214
- (aiClient.analyzeSensors as ReturnType<typeof vi.fn>).mockResolvedValue(mockAnalysis);
215
-
216
- registerAnalyzeSensorsTool(server, aiClient);
217
- const handler = server.handlers.get('analyzeSensors')!.handler;
218
-
219
- const sensorData = { 'sensor.test': { state: '10' } };
220
- const result = await handler({ sensors: sensorData }, mockExtra);
221
- const parsed = parseResult(result);
222
-
223
- expect(parsed).toEqual(mockAnalysis);
224
- expect(aiClient.analyzeSensors).toHaveBeenCalled();
225
- });
226
- });
227
- });
@@ -1,123 +0,0 @@
1
- /**
2
- * Common tool utilities tests
3
- */
4
-
5
- import { describe, it, expect, vi } from 'vitest';
6
- import { wrapToolHandler, toToolResult, Permission } from '../../src/tools/common.js';
7
- import type { ToolExtra } from '../../src/tools/common.js';
8
-
9
- describe('toToolResult', () => {
10
- it('should convert string to text content', () => {
11
- const result = toToolResult('hello');
12
- expect(result.content).toEqual([{ type: 'text', text: 'hello' }]);
13
- expect(result.isError).toBe(false);
14
- });
15
-
16
- it('should convert object to JSON text content', () => {
17
- const result = toToolResult({ foo: 'bar' });
18
- expect(result.content[0].type).toBe('text');
19
- expect(JSON.parse((result.content[0] as { text: string }).text)).toEqual({ foo: 'bar' });
20
- });
21
-
22
- it('should set isError when specified', () => {
23
- const result = toToolResult('error', true);
24
- expect(result.isError).toBe(true);
25
- });
26
- });
27
-
28
- describe('wrapToolHandler permission checks', () => {
29
- // Helper to create ToolExtra with permissions in http.authInfo.extra (MCP SDK v2 context
30
- // shape). No permissions => no `http` at all, as on a request without auth info.
31
- const createExtra = (permissions?: number): ToolExtra =>
32
- ({
33
- sessionId: 'test-session',
34
- mcpReq: { id: 1, signal: new AbortController().signal },
35
- ...(permissions !== undefined
36
- ? { http: { authInfo: { token: 'test-token', clientId: 'test-client', scopes: [], extra: { permissions } } } }
37
- : {}),
38
- }) as unknown as ToolExtra;
39
-
40
- it('should allow access when user has required permission', async () => {
41
- const handler = vi.fn().mockResolvedValue(toToolResult('success'));
42
- const wrapped = wrapToolHandler('testTool', handler, Permission.QUERY);
43
-
44
- const extra = createExtra(Permission.QUERY);
45
- const result = await wrapped({}, extra);
46
-
47
- expect(handler).toHaveBeenCalled();
48
- expect(result.isError).toBe(false);
49
- });
50
-
51
- it('should deny access when user lacks required permission', async () => {
52
- const handler = vi.fn().mockResolvedValue(toToolResult('success'));
53
- const wrapped = wrapToolHandler('testTool', handler, Permission.ADMIN);
54
-
55
- const extra = createExtra(Permission.QUERY); // Only QUERY, not ADMIN
56
- const result = await wrapped({}, extra);
57
-
58
- expect(handler).not.toHaveBeenCalled();
59
- expect(result.isError).toBe(true);
60
- const text = (result.content[0] as { text: string }).text;
61
- expect(text).toContain('Permission denied');
62
- expect(text).toContain('ADMIN');
63
- });
64
-
65
- it('should allow access with combined permissions', async () => {
66
- const handler = vi.fn().mockResolvedValue(toToolResult('success'));
67
- const wrapped = wrapToolHandler('testTool', handler, Permission.CONTROL);
68
-
69
- // User has QUERY | CONTROL | NOTIFY
70
- const extra = createExtra(Permission.QUERY | Permission.CONTROL | Permission.NOTIFY);
71
- const result = await wrapped({}, extra);
72
-
73
- expect(handler).toHaveBeenCalled();
74
- expect(result.isError).toBe(false);
75
- });
76
-
77
- it('should fail closed (deny) when authInfo is not set (L3)', async () => {
78
- const handler = vi.fn().mockResolvedValue(toToolResult('success'));
79
- const wrapped = wrapToolHandler('testTool', handler, Permission.ADMIN);
80
-
81
- // A missing permission mask must DENY, not default-allow. The trusted-local
82
- // stdio path opts into full trust separately (see insecureDesign.test.ts).
83
- const extra = createExtra(undefined);
84
- const result = await wrapped({}, extra);
85
-
86
- expect(handler).not.toHaveBeenCalled();
87
- expect(result.isError).toBe(true);
88
- });
89
-
90
- it('should allow access when no permission required', async () => {
91
- const handler = vi.fn().mockResolvedValue(toToolResult('success'));
92
- const wrapped = wrapToolHandler('testTool', handler); // No permission required
93
-
94
- const extra = createExtra(0); // No permissions
95
- const result = await wrapped({}, extra);
96
-
97
- expect(handler).toHaveBeenCalled();
98
- expect(result.isError).toBe(false);
99
- });
100
-
101
- it('should catch and handle errors from handler', async () => {
102
- const handler = vi.fn().mockRejectedValue(new Error('Something went wrong'));
103
- const wrapped = wrapToolHandler('testTool', handler, Permission.QUERY);
104
-
105
- const extra = createExtra(Permission.QUERY);
106
- const result = await wrapped({}, extra);
107
-
108
- expect(result.isError).toBe(true);
109
- const text = (result.content[0] as { text: string }).text;
110
- expect(text).toContain('Something went wrong');
111
- });
112
- });
113
-
114
- describe('Permission values', () => {
115
- it('should have correct bit values sorted by criticality', () => {
116
- expect(Permission.ADMIN).toBe(1); // Highest criticality
117
- expect(Permission.CONFIGURE).toBe(2);
118
- expect(Permission.CONTROL).toBe(4);
119
- expect(Permission.QUERY).toBe(8);
120
- expect(Permission.NOTIFY).toBe(16);
121
- expect(Permission.AI).toBe(32);
122
- });
123
- });