@coffeerunhobby/mcp-ha-connect 1.3.0 → 1.3.4

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 (65) hide show
  1. package/.env.example +9 -0
  2. package/CLAUDE.md +70 -8
  3. package/README.md +26 -0
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.js +6 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/index.js +19 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/omadaClient/index.d.ts +29 -0
  10. package/dist/omadaClient/index.js +41 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/request.js +14 -1
  13. package/dist/omadaClient/request.js.map +1 -1
  14. package/dist/omadaClient/site.d.ts +16 -0
  15. package/dist/omadaClient/site.js +25 -0
  16. package/dist/omadaClient/site.js.map +1 -1
  17. package/dist/permissions/index.js +20 -5
  18. package/dist/permissions/index.js.map +1 -1
  19. package/dist/server/common.d.ts +3 -0
  20. package/dist/server/common.js +2 -1
  21. package/dist/server/common.js.map +1 -1
  22. package/dist/server/stdio.d.ts +3 -0
  23. package/dist/server/stdio.js.map +1 -1
  24. package/dist/server/stream.js +6 -1
  25. package/dist/server/stream.js.map +1 -1
  26. package/dist/tools/common.d.ts +12 -0
  27. package/dist/tools/common.js +15 -1
  28. package/dist/tools/common.js.map +1 -1
  29. package/dist/tools/index.d.ts +3 -0
  30. package/dist/tools/index.js +2 -2
  31. package/dist/tools/index.js.map +1 -1
  32. package/dist/tools/omada/graph.d.ts +22 -0
  33. package/dist/tools/omada/graph.js +120 -0
  34. package/dist/tools/omada/graph.js.map +1 -0
  35. package/dist/tools/omada/index.d.ts +9 -1
  36. package/dist/tools/omada/index.js +30 -3
  37. package/dist/tools/omada/index.js.map +1 -1
  38. package/dist/tools/omada/namespace.d.ts +89 -0
  39. package/dist/tools/omada/namespace.js +496 -0
  40. package/dist/tools/omada/namespace.js.map +1 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/docs/CHANGELOG.md +63 -0
  44. package/openapi.json +1 -1
  45. package/package.json +1 -1
  46. package/src/config.ts +7 -0
  47. package/src/index.ts +19 -2
  48. package/src/omadaClient/index.ts +65 -0
  49. package/src/omadaClient/request.ts +14 -1
  50. package/src/omadaClient/site.ts +29 -0
  51. package/src/permissions/index.ts +22 -5
  52. package/src/server/common.ts +5 -1
  53. package/src/server/stdio.ts +3 -0
  54. package/src/server/stream.ts +6 -1
  55. package/src/tools/common.ts +16 -2
  56. package/src/tools/index.ts +5 -3
  57. package/src/tools/omada/graph.ts +162 -0
  58. package/src/tools/omada/index.ts +48 -3
  59. package/src/tools/omada/namespace.ts +577 -0
  60. package/src/version.ts +1 -1
  61. package/tests/config.test.ts +30 -0
  62. package/tests/omadaClient/site.test.ts +37 -0
  63. package/tests/permissions/index.test.ts +21 -0
  64. package/tests/tools/omada/graph.test.ts +352 -0
  65. package/tests/tools/omada/namespace.test.ts +256 -0
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Omada resource-graph namespace.
3
+ *
4
+ * Instead of registering one MCP tool per Omada endpoint (which explodes the
5
+ * tool-schema budget for small models), graph mode exposes a single discoverable
6
+ * namespace via `omada_browse` (navigate) + `omada_read` (fetch). This manifest is
7
+ * the source of truth mapping graph paths → fetch logic + per-path RBAC.
8
+ *
9
+ * Design (see the "resource graph with lazy expansion" discussion):
10
+ * - `browse` returns resource *types/metadata*, never instances — no enumeration,
11
+ * no controller calls, zero payload explosion.
12
+ * - `read` is where data is fetched: single leaves, collections (optionally a
13
+ * single member via `id`), or paginated logs (single page via page/pageSize).
14
+ * - Authorization is per-path: each node declares the Permission bit it needs.
15
+ * Most nodes are QUERY; the security subtree (`/security/threats`) requires
16
+ * ADMIN, demonstrating that sensitive subtrees can require a higher bit without
17
+ * touching the tools.
18
+ *
19
+ * `graph` mode is a complete superset of `eager` reads: every eager read getter
20
+ * has a corresponding graph node here, including the ADMIN-gated IDS/IPS
21
+ * threat-management log (`/security/threats`), which maps its mandatory epoch
22
+ * time-window onto the required `params.startTime`/`params.endTime` validated by
23
+ * `omada_read` before any controller call.
24
+ */
25
+
26
+ import type { OmadaClient } from '../../omadaClient/index.js';
27
+ import { Permission } from '../../permissions/index.js';
28
+
29
+ /** Arguments passed from `omada_read` to a node's fetch function. */
30
+ export interface ReadArgs {
31
+ siteId?: string;
32
+ /** Look up a single member of a collection by id/MAC. */
33
+ id?: string;
34
+ /** Path parameters some resources require (e.g. gatewayMac, apMac, deviceMac). */
35
+ params?: Record<string, string>;
36
+ page?: number;
37
+ pageSize?: number;
38
+ }
39
+
40
+ /** A declared parameter for a resource (surfaced by browse, validated by read). */
41
+ export interface ResourceParam {
42
+ name: string;
43
+ required: boolean;
44
+ description: string;
45
+ }
46
+
47
+ export type ResourceKind = 'container' | 'collection' | 'leaf';
48
+
49
+ export interface ResourceNode {
50
+ /** Graph path, e.g. '/gateway/wan'. Always normalized (leading slash, no trailing). */
51
+ path: string;
52
+ kind: ResourceKind;
53
+ /** Permission bit required to browse-to / read this node. */
54
+ permission: number;
55
+ description: string;
56
+ estimatedSize?: 'small' | 'medium' | 'large';
57
+ paginated?: boolean;
58
+ defaultPageSize?: number;
59
+ params?: ResourceParam[];
60
+ /** Undefined for pure containers (browse-only — no data of their own). */
61
+ fetch?: (client: OmadaClient, args: ReadArgs) => Promise<unknown>;
62
+ }
63
+
64
+ const Q = Permission.QUERY;
65
+ const A = Permission.ADMIN;
66
+
67
+ const HOUR_MS = 60 * 60 * 1000;
68
+
69
+ /**
70
+ * Resolve an epoch-MILLISECONDS time window for the event/alert log endpoints
71
+ * (`/sites/{siteId}/logs/{events,alerts}`), which require `filters.timeStart` and
72
+ * `filters.timeEnd`. Explicit `params.startTime`/`params.endTime` win; otherwise
73
+ * default to the last 7 days so a bare `omada_read('/events')` "just works".
74
+ * Exported for unit testing (inject `now` to keep tests deterministic).
75
+ */
76
+ export function resolveLogWindowMs(
77
+ params: Record<string, string> | undefined,
78
+ now = Date.now()
79
+ ): { timeStart: number; timeEnd: number } {
80
+ const timeEnd = params?.endTime ? Number(params.endTime) : now;
81
+ const timeStart = params?.startTime ? Number(params.startTime) : timeEnd - 7 * 24 * HOUR_MS;
82
+ return { timeStart, timeEnd };
83
+ }
84
+
85
+ /**
86
+ * Resolve an epoch-SECONDS time window for the dashboard top-usage endpoints
87
+ * (`top-device-cpu-usage` / `top-device-memory-usage`), which require `start` and
88
+ * `end`. Explicit `params.startTime`/`params.endTime` (seconds) win; otherwise
89
+ * default to the last 24 hours. Exported for unit testing.
90
+ */
91
+ export function resolveUsageWindowSec(
92
+ params: Record<string, string> | undefined,
93
+ now = Date.now()
94
+ ): { start: number; end: number } {
95
+ const end = params?.endTime ? Number(params.endTime) : Math.floor(now / 1000);
96
+ const start = params?.startTime ? Number(params.startTime) : end - 24 * 60 * 60;
97
+ return { start, end };
98
+ }
99
+
100
+ /**
101
+ * The Omada resource graph. Containers have no `fetch` (browse-only); collections
102
+ * and leaves fetch data. Existing client methods back the well-trodden reads;
103
+ * the generic `client.readResource(...)` backs newly-added endpoints with zero
104
+ * new client code.
105
+ */
106
+ export const OMADA_RESOURCES: ResourceNode[] = [
107
+ {
108
+ path: '/',
109
+ kind: 'container',
110
+ permission: Q,
111
+ description: 'Root of the Omada network resource graph. Browse children to discover resources.',
112
+ },
113
+
114
+ // ---- Sites -------------------------------------------------------------
115
+ {
116
+ path: '/sites',
117
+ kind: 'collection',
118
+ permission: Q,
119
+ estimatedSize: 'small',
120
+ description: 'All sites on the controller. Most resources are scoped to a site via the optional siteId argument.',
121
+ fetch: (c) => c.listSites(),
122
+ },
123
+
124
+ // ---- Devices (infrastructure) -----------------------------------------
125
+ {
126
+ path: '/devices',
127
+ kind: 'collection',
128
+ permission: Q,
129
+ estimatedSize: 'medium',
130
+ description: 'Network infrastructure (APs, switches, gateways). Pass id=<MAC> to look up a single device.',
131
+ params: [{ name: 'id', required: false, description: 'Device MAC to fetch a single device' }],
132
+ fetch: (c, a) => (a.id ? c.getDevice(a.id, a.siteId) : c.listDevices(a.siteId)),
133
+ },
134
+ {
135
+ path: '/devices/pending',
136
+ kind: 'collection',
137
+ permission: Q,
138
+ paginated: true,
139
+ defaultPageSize: 50,
140
+ estimatedSize: 'small',
141
+ description: 'Devices discovered but not yet adopted into the site (paginated). Use page/pageSize.',
142
+ fetch: (c, a) =>
143
+ c.readResource({
144
+ pathTemplate: '/sites/{siteId}/grid/devices/pending',
145
+ siteId: a.siteId,
146
+ paginated: true,
147
+ page: a.page,
148
+ pageSize: a.pageSize,
149
+ }),
150
+ },
151
+ {
152
+ path: '/devices/stats',
153
+ kind: 'collection',
154
+ permission: Q,
155
+ paginated: true,
156
+ defaultPageSize: 50,
157
+ estimatedSize: 'medium',
158
+ description: 'Per-device statistics (CPU, memory, traffic). Paginated — use page/pageSize.',
159
+ fetch: (c, a) => c.listDevicesStats({ page: a.page ?? 1, pageSize: a.pageSize ?? 50 }),
160
+ },
161
+ {
162
+ path: '/devices/stack',
163
+ kind: 'leaf',
164
+ permission: Q,
165
+ estimatedSize: 'small',
166
+ description: 'Switch-stack detail. Requires stackId.',
167
+ params: [{ name: 'stackId', required: true, description: 'Switch stack identifier' }],
168
+ fetch: (c, a) => c.getSwitchStackDetail(a.params?.stackId ?? '', a.siteId),
169
+ },
170
+
171
+ // ---- Clients (connected users) ----------------------------------------
172
+ {
173
+ path: '/clients',
174
+ kind: 'collection',
175
+ permission: Q,
176
+ estimatedSize: 'large',
177
+ description: 'Connected clients (phones, laptops, IoT). Pass id=<MAC> to look up a single client.',
178
+ params: [{ name: 'id', required: false, description: 'Client MAC to fetch a single client' }],
179
+ fetch: (c, a) => (a.id ? c.getClient(a.id, a.siteId) : c.listClients(a.siteId)),
180
+ },
181
+ {
182
+ path: '/clients/active',
183
+ kind: 'collection',
184
+ permission: Q,
185
+ estimatedSize: 'small',
186
+ description: 'Most active clients by traffic.',
187
+ fetch: (c, a) => c.listMostActiveClients(a.siteId),
188
+ },
189
+ {
190
+ path: '/clients/activity',
191
+ kind: 'collection',
192
+ permission: Q,
193
+ estimatedSize: 'medium',
194
+ description: 'Per-client activity (traffic over time). Optional time window via params.start/params.end (epoch seconds).',
195
+ params: [
196
+ { name: 'start', required: false, description: 'Start timestamp, epoch seconds' },
197
+ { name: 'end', required: false, description: 'End timestamp, epoch seconds' },
198
+ ],
199
+ fetch: (c, a) =>
200
+ c.listClientsActivity({
201
+ siteId: a.siteId,
202
+ start: a.params?.start !== undefined ? Number(a.params.start) : undefined,
203
+ end: a.params?.end !== undefined ? Number(a.params.end) : undefined,
204
+ }),
205
+ },
206
+ {
207
+ path: '/clients/past-connections',
208
+ kind: 'collection',
209
+ permission: Q,
210
+ paginated: true,
211
+ defaultPageSize: 50,
212
+ estimatedSize: 'large',
213
+ description: 'Historical client connections (paginated). Use page/pageSize.',
214
+ fetch: (c, a) => c.listClientsPastConnections({ siteId: a.siteId, page: a.page ?? 1, pageSize: a.pageSize ?? 50 }),
215
+ },
216
+
217
+ // ---- Gateway -----------------------------------------------------------
218
+ { path: '/gateway', kind: 'container', permission: Q, description: 'Gateway/router status and connectivity.' },
219
+ {
220
+ path: '/gateway/wan',
221
+ kind: 'leaf',
222
+ permission: Q,
223
+ estimatedSize: 'small',
224
+ description: 'WAN port status for a gateway: WAN IP, DNS, uptime, link speed, TX/RX. Requires gatewayMac (find via /devices).',
225
+ params: [{ name: 'gatewayMac', required: true, description: 'Gateway MAC address' }],
226
+ fetch: (c, a) =>
227
+ c.readResource({
228
+ pathTemplate: '/sites/{siteId}/gateways/{gatewayMac}/wan-status',
229
+ siteId: a.siteId,
230
+ pathParams: { gatewayMac: a.params?.gatewayMac ?? '' },
231
+ }),
232
+ },
233
+ {
234
+ path: '/gateway/health',
235
+ kind: 'leaf',
236
+ permission: Q,
237
+ estimatedSize: 'small',
238
+ description: 'Detailed per-WAN health for a gateway. Requires gatewayMac (find via /devices).',
239
+ params: [{ name: 'gatewayMac', required: true, description: 'Gateway MAC address' }],
240
+ fetch: (c, a) =>
241
+ c.readResource({
242
+ pathTemplate: '/sites/{siteId}/health/gateways/{gatewayMac}/wans/details',
243
+ siteId: a.siteId,
244
+ pathParams: { gatewayMac: a.params?.gatewayMac ?? '' },
245
+ }),
246
+ },
247
+
248
+ // ---- Network config ----------------------------------------------------
249
+ { path: '/network', kind: 'container', permission: Q, description: 'Network configuration: internet, LANs, WLAN groups, firewall.' },
250
+ {
251
+ path: '/network/internet',
252
+ kind: 'leaf',
253
+ permission: Q,
254
+ estimatedSize: 'small',
255
+ description: 'Internet/WAN connection information for the site.',
256
+ fetch: (c, a) => c.getInternetInfo(a.siteId),
257
+ },
258
+ {
259
+ path: '/network/lan-networks',
260
+ kind: 'collection',
261
+ permission: Q,
262
+ estimatedSize: 'small',
263
+ description: 'Configured LAN networks / VLANs.',
264
+ fetch: (c, a) => c.getLanNetworkList(a.siteId),
265
+ },
266
+ {
267
+ path: '/network/lan-profiles',
268
+ kind: 'collection',
269
+ permission: Q,
270
+ estimatedSize: 'small',
271
+ description: 'LAN profiles.',
272
+ fetch: (c, a) => c.getLanProfileList(a.siteId),
273
+ },
274
+ {
275
+ path: '/network/wlan-groups',
276
+ kind: 'collection',
277
+ permission: Q,
278
+ estimatedSize: 'small',
279
+ description: 'WLAN groups.',
280
+ fetch: (c, a) => c.getWlanGroupList(a.siteId),
281
+ },
282
+ {
283
+ path: '/network/firewall',
284
+ kind: 'leaf',
285
+ permission: Q,
286
+ estimatedSize: 'small',
287
+ description: 'Gateway firewall settings for the site.',
288
+ fetch: (c, a) => c.getFirewallSetting(a.siteId),
289
+ },
290
+ {
291
+ path: '/network/port-forwarding',
292
+ kind: 'collection',
293
+ permission: Q,
294
+ paginated: true,
295
+ defaultPageSize: 10,
296
+ estimatedSize: 'small',
297
+ description: "Port-forwarding status (paginated). params.type is 'User' (default) or 'UPnP'.",
298
+ params: [{ name: 'type', required: false, description: "'User' (default) or 'UPnP'" }],
299
+ fetch: (c, a) =>
300
+ c.getPortForwardingStatus(a.params?.type === 'UPnP' ? 'UPnP' : 'User', a.siteId, a.page ?? 1, a.pageSize ?? 10),
301
+ },
302
+ {
303
+ path: '/network/rate-limit-profiles',
304
+ kind: 'collection',
305
+ permission: Q,
306
+ estimatedSize: 'small',
307
+ description: 'Configured client rate-limit profiles (input for the rate-limit write tools).',
308
+ fetch: (c, a) => c.getRateLimitProfiles(a.siteId),
309
+ },
310
+
311
+ // ---- WiFi / wireless security -----------------------------------------
312
+ { path: '/wifi', kind: 'container', permission: Q, description: 'Wireless: SSIDs, speed tests, rogue-AP and intrusion detection.' },
313
+ {
314
+ path: '/wifi/speedtest',
315
+ kind: 'leaf',
316
+ permission: Q,
317
+ estimatedSize: 'small',
318
+ description: 'Latest speed-test result for an access point. Requires apMac (find via /devices).',
319
+ params: [{ name: 'apMac', required: true, description: 'Access point MAC address' }],
320
+ fetch: (c, a) =>
321
+ c.readResource({
322
+ pathTemplate: '/sites/{siteId}/aps/{apMac}/speed-test-result',
323
+ siteId: a.siteId,
324
+ pathParams: { apMac: a.params?.apMac ?? '' },
325
+ }),
326
+ },
327
+ {
328
+ path: '/wifi/rogue',
329
+ kind: 'collection',
330
+ permission: Q,
331
+ paginated: true,
332
+ defaultPageSize: 50,
333
+ estimatedSize: 'small',
334
+ description: 'Rogue access points detected near the site (paginated). Use page/pageSize.',
335
+ fetch: (c, a) =>
336
+ c.readResource({
337
+ pathTemplate: '/sites/{siteId}/insight/rogueaps',
338
+ siteId: a.siteId,
339
+ paginated: true,
340
+ page: a.page,
341
+ pageSize: a.pageSize,
342
+ }),
343
+ },
344
+ {
345
+ path: '/wifi/wids',
346
+ kind: 'collection',
347
+ permission: Q,
348
+ paginated: true,
349
+ defaultPageSize: 50,
350
+ estimatedSize: 'small',
351
+ description:
352
+ 'Wireless Intrusion Detection System (WIDS) entries (paginated). Use page/pageSize. ' +
353
+ 'Note: the controller restricts this endpoint to Omada Pro controllers/sites; on a ' +
354
+ 'standard controller it returns a "Pro only" message rather than data.',
355
+ fetch: (c, a) =>
356
+ c.readResource({
357
+ pathTemplate: '/sites/{siteId}/insight/wids',
358
+ siteId: a.siteId,
359
+ paginated: true,
360
+ page: a.page,
361
+ pageSize: a.pageSize,
362
+ }),
363
+ },
364
+ {
365
+ path: '/wifi/ssids',
366
+ kind: 'collection',
367
+ permission: Q,
368
+ estimatedSize: 'small',
369
+ description: 'SSIDs in a WLAN group. Requires wlanId; pass params.ssidId too to fetch a single SSID detail.',
370
+ params: [
371
+ { name: 'wlanId', required: true, description: 'WLAN group id (find via /network/wlan-groups)' },
372
+ { name: 'ssidId', required: false, description: 'SSID id — when set, returns that SSID detail instead of the list' },
373
+ ],
374
+ fetch: (c, a) =>
375
+ a.params?.ssidId
376
+ ? c.getSsidDetail(a.params.wlanId ?? '', a.params.ssidId, a.siteId)
377
+ : c.getSsidList(a.params?.wlanId ?? '', a.siteId),
378
+ },
379
+
380
+ // ---- Events / alerts (paginated) --------------------------------------
381
+ {
382
+ path: '/events',
383
+ kind: 'collection',
384
+ permission: Q,
385
+ paginated: true,
386
+ defaultPageSize: 50,
387
+ estimatedSize: 'large',
388
+ description:
389
+ 'Site event log (paginated). Use page/pageSize. The controller requires a time window: by default this ' +
390
+ 'returns the last 7 days. Override with params.startTime / params.endTime (epoch MILLISECONDS, e.g. ' +
391
+ '1679297710438). Optional params.module filters by source module.',
392
+ params: [
393
+ { name: 'startTime', required: false, description: 'Window start, epoch ms (default: 7 days ago)' },
394
+ { name: 'endTime', required: false, description: 'Window end, epoch ms (default: now)' },
395
+ { name: 'module', required: false, description: 'Filter by module (e.g. "Device", "Client", "User")' },
396
+ ],
397
+ fetch: (c, a) => {
398
+ const { timeStart, timeEnd } = resolveLogWindowMs(a.params);
399
+ const query: Record<string, unknown> = { 'filters.timeStart': timeStart, 'filters.timeEnd': timeEnd };
400
+ if (a.params?.module) {
401
+ query['filters.module'] = a.params.module;
402
+ }
403
+ return c.readResource({ pathTemplate: '/sites/{siteId}/logs/events', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize, query });
404
+ },
405
+ },
406
+ {
407
+ path: '/events/alerts',
408
+ kind: 'collection',
409
+ permission: Q,
410
+ paginated: true,
411
+ defaultPageSize: 50,
412
+ estimatedSize: 'large',
413
+ description:
414
+ 'Site alert log (paginated). Use page/pageSize. The controller requires a time window: by default this ' +
415
+ 'returns the last 7 days. Override with params.startTime / params.endTime (epoch MILLISECONDS). Optional ' +
416
+ 'params.module filters by source module; params.resolved ("true"/"false") filters by resolution state.',
417
+ params: [
418
+ { name: 'startTime', required: false, description: 'Window start, epoch ms (default: 7 days ago)' },
419
+ { name: 'endTime', required: false, description: 'Window end, epoch ms (default: now)' },
420
+ { name: 'module', required: false, description: 'Filter by module (e.g. "Device", "Client", "User")' },
421
+ { name: 'resolved', required: false, description: '"true" or "false" to filter by resolution state' },
422
+ ],
423
+ fetch: (c, a) => {
424
+ const { timeStart, timeEnd } = resolveLogWindowMs(a.params);
425
+ const query: Record<string, unknown> = { 'filters.timeStart': timeStart, 'filters.timeEnd': timeEnd };
426
+ if (a.params?.module) {
427
+ query['filters.module'] = a.params.module;
428
+ }
429
+ if (a.params?.resolved === 'true' || a.params?.resolved === 'false') {
430
+ query['filters.resolved'] = a.params.resolved;
431
+ }
432
+ return c.readResource({ pathTemplate: '/sites/{siteId}/logs/alerts', siteId: a.siteId, paginated: true, page: a.page, pageSize: a.pageSize, query });
433
+ },
434
+ },
435
+
436
+ // ---- Dashboard ---------------------------------------------------------
437
+ {
438
+ path: '/dashboard',
439
+ kind: 'leaf',
440
+ permission: Q,
441
+ estimatedSize: 'medium',
442
+ description: 'Site dashboard overview diagram (topology + summary).',
443
+ fetch: (c, a) => c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/overview-diagram', siteId: a.siteId }),
444
+ },
445
+ {
446
+ path: '/dashboard/cpu',
447
+ kind: 'collection',
448
+ permission: Q,
449
+ estimatedSize: 'small',
450
+ description:
451
+ 'Devices with the highest CPU usage over a time window (default: last 24h). Override with params.startTime / ' +
452
+ 'params.endTime (epoch SECONDS, e.g. 1682000000).',
453
+ params: [
454
+ { name: 'startTime', required: false, description: 'Window start, epoch seconds (default: 24h ago)' },
455
+ { name: 'endTime', required: false, description: 'Window end, epoch seconds (default: now)' },
456
+ ],
457
+ fetch: (c, a) =>
458
+ c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/top-device-cpu-usage', siteId: a.siteId, query: resolveUsageWindowSec(a.params) }),
459
+ },
460
+ {
461
+ path: '/dashboard/memory',
462
+ kind: 'collection',
463
+ permission: Q,
464
+ estimatedSize: 'small',
465
+ description:
466
+ 'Devices with the highest memory usage over a time window (default: last 24h). Override with params.startTime / ' +
467
+ 'params.endTime (epoch SECONDS, e.g. 1682000000).',
468
+ params: [
469
+ { name: 'startTime', required: false, description: 'Window start, epoch seconds (default: 24h ago)' },
470
+ { name: 'endTime', required: false, description: 'Window end, epoch seconds (default: now)' },
471
+ ],
472
+ fetch: (c, a) =>
473
+ c.readResource({ pathTemplate: '/sites/{siteId}/dashboard/top-device-memory-usage', siteId: a.siteId, query: resolveUsageWindowSec(a.params) }),
474
+ },
475
+
476
+ // ---- Firmware ----------------------------------------------------------
477
+ {
478
+ path: '/firmware',
479
+ kind: 'leaf',
480
+ permission: Q,
481
+ estimatedSize: 'small',
482
+ description: 'Latest available firmware info for a device. Requires deviceMac (find via /devices).',
483
+ params: [{ name: 'deviceMac', required: true, description: 'Device MAC address' }],
484
+ fetch: (c, a) =>
485
+ c.readResource({
486
+ pathTemplate: '/sites/{siteId}/devices/{deviceMac}/latest-firmware-info',
487
+ siteId: a.siteId,
488
+ pathParams: { deviceMac: a.params?.deviceMac ?? '' },
489
+ }),
490
+ },
491
+ {
492
+ path: '/firmware/critical',
493
+ kind: 'leaf',
494
+ permission: Q,
495
+ estimatedSize: 'small',
496
+ description: 'Controller-wide overview of critical firmware upgrades (not site-scoped).',
497
+ fetch: (c) => c.readResource({ pathTemplate: '/upgrade/overview/critical', siteScoped: false }),
498
+ },
499
+
500
+ // ---- Security (IDS/IPS) — ADMIN only -----------------------------------
501
+ {
502
+ path: '/security',
503
+ kind: 'container',
504
+ permission: A,
505
+ description: 'Security: IDS/IPS threat-management log. ADMIN only.',
506
+ },
507
+ {
508
+ path: '/security/threats',
509
+ kind: 'collection',
510
+ permission: A,
511
+ paginated: true,
512
+ defaultPageSize: 10,
513
+ estimatedSize: 'large',
514
+ description:
515
+ 'IDS/IPS threat-management log (paginated, ADMIN). REQUIRES a time window: params.startTime and params.endTime ' +
516
+ '(epoch SECONDS, e.g. 1682000000). Optional params: archived ("true"/"false", default false), severity ' +
517
+ '(0=Critical,1=Major,2=Concerning,3=Minor), sortTime ("asc"/"desc"), searchKey (fuzzy text), siteList ' +
518
+ '(comma-separated site IDs; default all sites).',
519
+ params: [
520
+ { name: 'startTime', required: true, description: 'Window start, epoch seconds (e.g. 1682000000)' },
521
+ { name: 'endTime', required: true, description: 'Window end, epoch seconds (e.g. 1682003600)' },
522
+ { name: 'archived', required: false, description: '"true" or "false" (default false)' },
523
+ { name: 'severity', required: false, description: '0=Critical, 1=Major, 2=Concerning, 3=Minor' },
524
+ { name: 'sortTime', required: false, description: 'Sort by time: "asc" or "desc"' },
525
+ { name: 'searchKey', required: false, description: 'Fuzzy search on description/classification' },
526
+ { name: 'siteList', required: false, description: 'Comma-separated site IDs (default: all sites)' },
527
+ ],
528
+ fetch: (c, a) =>
529
+ c.getThreatList({
530
+ siteList: a.params?.siteList,
531
+ archived: a.params?.archived === 'true',
532
+ page: a.page ?? 1,
533
+ pageSize: a.pageSize ?? 10,
534
+ startTime: Number(a.params?.startTime),
535
+ endTime: Number(a.params?.endTime),
536
+ severity: a.params?.severity !== undefined ? Number(a.params.severity) : undefined,
537
+ sortTime: a.params?.sortTime === 'asc' ? 'asc' : a.params?.sortTime === 'desc' ? 'desc' : undefined,
538
+ searchKey: a.params?.searchKey,
539
+ }),
540
+ },
541
+ ];
542
+
543
+ const byPath = new Map<string, ResourceNode>(OMADA_RESOURCES.map((n) => [n.path, n]));
544
+
545
+ /** Normalize a user-supplied path: leading slash, no trailing slash, '/' for root. */
546
+ export function normalizePath(path: string): string {
547
+ if (!path || path === '/') {
548
+ return '/';
549
+ }
550
+ let p = path.trim();
551
+ if (!p.startsWith('/')) {
552
+ p = `/${p}`;
553
+ }
554
+ if (p.length > 1 && p.endsWith('/')) {
555
+ p = p.slice(0, -1);
556
+ }
557
+ return p;
558
+ }
559
+
560
+ /** The immediate parent path, or null for the root. */
561
+ function parentPath(path: string): string | null {
562
+ if (path === '/') {
563
+ return null;
564
+ }
565
+ const idx = path.lastIndexOf('/');
566
+ return idx <= 0 ? '/' : path.slice(0, idx);
567
+ }
568
+
569
+ export function getResourceNode(path: string): ResourceNode | undefined {
570
+ return byPath.get(normalizePath(path));
571
+ }
572
+
573
+ /** Direct children (one segment deeper) of the given path. */
574
+ export function childrenOf(path: string): ResourceNode[] {
575
+ const norm = normalizePath(path);
576
+ return OMADA_RESOURCES.filter((n) => parentPath(n.path) === norm);
577
+ }
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '1.3.0';
5
+ export const VERSION = '1.3.4';
@@ -51,10 +51,40 @@ describe('loadConfig', () => {
51
51
  expect(config.logFormat).toBe('plain'); // default
52
52
  expect(config.useHttp).toBe(false); // default
53
53
  expect(config.stateful).toBe(false); // default
54
+ expect(config.toolRegistrationMode).toBe('eager'); // default
54
55
  expect(config.httpEnableHealthcheck).toBe(true); // default
55
56
  expect(config.httpAllowCors).toBe(true); // default
56
57
  });
57
58
 
59
+ describe('toolRegistrationMode (MCP_TOOL_REGISTRATION_MODE)', () => {
60
+ it('defaults to "eager" when unset', () => {
61
+ const config = loadConfig({
62
+ HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
63
+ HA_TOKEN: 'test-token-12345',
64
+ });
65
+ expect(config.toolRegistrationMode).toBe('eager');
66
+ });
67
+
68
+ it('parses "graph"', () => {
69
+ const config = loadConfig({
70
+ HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
71
+ HA_TOKEN: 'test-token-12345',
72
+ MCP_TOOL_REGISTRATION_MODE: 'graph',
73
+ });
74
+ expect(config.toolRegistrationMode).toBe('graph');
75
+ });
76
+
77
+ it('rejects an unknown mode', () => {
78
+ expect(() =>
79
+ loadConfig({
80
+ HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
81
+ HA_TOKEN: 'test-token-12345',
82
+ MCP_TOOL_REGISTRATION_MODE: 'lazy',
83
+ })
84
+ ).toThrow('Invalid environment configuration');
85
+ });
86
+ });
87
+
58
88
  it('should remove trailing slash from baseUrl', () => {
59
89
  const env = {
60
90
  HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123/',
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { checkConfiguredSite } from '../../src/omadaClient/site.js';
4
+ import type { OmadaSiteSummary } from '../../src/types/index.js';
5
+
6
+ const site = (siteId: string, name: string): OmadaSiteSummary => ({ siteId, name });
7
+
8
+ const SITES: OmadaSiteSummary[] = [site('69bbeb9fa83b077caff61fee', 'Home'), site('ab12cd34', 'Office')];
9
+
10
+ describe('checkConfiguredSite', () => {
11
+ it('returns null when no OMADA_SITE_ID is configured (no default)', () => {
12
+ expect(checkConfiguredSite(undefined, SITES)).toBeNull();
13
+ expect(checkConfiguredSite('', SITES)).toBeNull();
14
+ });
15
+
16
+ it('returns null when the configured site exists on the controller', () => {
17
+ expect(checkConfiguredSite('69bbeb9fa83b077caff61fee', SITES)).toBeNull();
18
+ expect(checkConfiguredSite('ab12cd34', SITES)).toBeNull();
19
+ });
20
+
21
+ it('returns an actionable error when the configured site is not on the controller', () => {
22
+ const err = checkConfiguredSite('stale-old-id', SITES);
23
+ expect(err).not.toBeNull();
24
+ // Names the bad value...
25
+ expect(err).toContain('stale-old-id');
26
+ // ...and lists the valid choices (name + id) so the operator can fix .env.
27
+ expect(err).toContain('Home (69bbeb9fa83b077caff61fee)');
28
+ expect(err).toContain('Office (ab12cd34)');
29
+ });
30
+
31
+ it('still flags a stale id when the controller returns no sites', () => {
32
+ const err = checkConfiguredSite('stale-old-id', []);
33
+ expect(err).not.toBeNull();
34
+ expect(err).toContain('stale-old-id');
35
+ expect(err).toContain('(controller returned no sites)');
36
+ });
37
+ });