@coffeerunhobby/mcp-ha-connect 2.2.0 → 2.2.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 (46) hide show
  1. package/README.md +4 -2
  2. package/dist/omadaClient/client.d.ts +33 -1
  3. package/dist/omadaClient/client.js +61 -2
  4. package/dist/omadaClient/client.js.map +1 -1
  5. package/dist/omadaClient/index.d.ts +7 -0
  6. package/dist/omadaClient/index.js +6 -0
  7. package/dist/omadaClient/index.js.map +1 -1
  8. package/dist/omadaClient/request.d.ts +5 -0
  9. package/dist/omadaClient/request.js +11 -2
  10. package/dist/omadaClient/request.js.map +1 -1
  11. package/dist/tools/common.js +5 -1
  12. package/dist/tools/common.js.map +1 -1
  13. package/dist/tools/omada/blockClient.js +3 -1
  14. package/dist/tools/omada/blockClient.js.map +1 -1
  15. package/dist/tools/omada/deleteClient.d.ts +3 -0
  16. package/dist/tools/omada/deleteClient.js +15 -0
  17. package/dist/tools/omada/deleteClient.js.map +1 -0
  18. package/dist/tools/omada/index.js +7 -2
  19. package/dist/tools/omada/index.js.map +1 -1
  20. package/dist/tools/omada/listBlockedClients.d.ts +3 -0
  21. package/dist/tools/omada/listBlockedClients.js +9 -0
  22. package/dist/tools/omada/listBlockedClients.js.map +1 -0
  23. package/dist/tools/omada/namespace.js +17 -0
  24. package/dist/tools/omada/namespace.js.map +1 -1
  25. package/dist/utils/redact.d.ts +21 -0
  26. package/dist/utils/redact.js +53 -0
  27. package/dist/utils/redact.js.map +1 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/docs/CHANGELOG.md +9 -0
  31. package/openapi.json +1 -1
  32. package/package.json +1 -1
  33. package/src/omadaClient/client.ts +82 -4
  34. package/src/omadaClient/index.ts +9 -1
  35. package/src/omadaClient/request.ts +13 -2
  36. package/src/tools/common.ts +5 -1
  37. package/src/tools/omada/blockClient.ts +3 -1
  38. package/src/tools/omada/deleteClient.ts +27 -0
  39. package/src/tools/omada/index.ts +7 -2
  40. package/src/tools/omada/listBlockedClients.ts +17 -0
  41. package/src/tools/omada/namespace.ts +19 -0
  42. package/src/utils/redact.ts +59 -0
  43. package/src/version.ts +1 -1
  44. package/tests/omadaClient/clientBlocks.test.ts +170 -0
  45. package/tests/tools/omada/graph.test.ts +13 -2
  46. package/tests/utils/redact.test.ts +105 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Redaction of secrets in tool responses.
3
+ *
4
+ * Home Assistant and Omada return secrets inside ordinary read results: the
5
+ * Wi-Fi password (Omada `pskSetting.securityKey`), RADIUS / CoA / SNMP secrets,
6
+ * PPSK and VPN keys, portal passwords, camera access tokens in image URLs. A tool
7
+ * response goes to an AI client and from there into transcripts, so every tool
8
+ * response is passed through `redactSecrets` (see toToolResult).
9
+ *
10
+ * Only non-empty string values under secret-looking names are replaced, with
11
+ * "<redacted>": an empty value stays empty (so "is a password set?" is still
12
+ * answerable), and booleans or numbers under similar names (`hidePwd`,
13
+ * `encryptionPsk`) are left alone. `token=` / `access_token=` query values inside
14
+ * URLs are masked as well. The input is never modified.
15
+ *
16
+ * Applied at the response boundary only, never inside the API clients: internal
17
+ * read-modify-write paths (SSID schedule, site NTP) must see real values.
18
+ */
19
+
20
+ export const REDACTED = '<redacted>';
21
+
22
+ /**
23
+ * Secret-looking field names, matched on the whole name or its ending
24
+ * (case-insensitive), from the Omada Open API and Home Assistant responses.
25
+ */
26
+ const SECRET_NAME =
27
+ /(^key$|password|passwd|pwd$|secret|token$|passphrase|presharedkey|privatekey|securitykey|communitystring|^psk$|md5key|simplekey|authcode|devicekey)/i;
28
+
29
+ /** Credentials inside URLs, e.g. /api/camera_proxy/camera.door?token=abc123 */
30
+ const URL_SECRET = /([?&](?:access_token|token|key|password|secret)=)[^&#\s"]+/gi;
31
+
32
+ export function isSecretName(name: string): boolean {
33
+ return SECRET_NAME.test(name);
34
+ }
35
+
36
+ export function redactSecrets<T>(value: T): T {
37
+ return redact(value, new WeakSet()) as T;
38
+ }
39
+
40
+ function redact(value: unknown, seen: WeakSet<object>): unknown {
41
+ if (typeof value === 'string') {
42
+ return value.includes('=') ? value.replace(URL_SECRET, `$1${REDACTED}`) : value;
43
+ }
44
+ if (typeof value !== 'object' || value === null) {
45
+ return value;
46
+ }
47
+ if (seen.has(value)) {
48
+ return '[circular]';
49
+ }
50
+ seen.add(value);
51
+ if (Array.isArray(value)) {
52
+ return value.map((item) => redact(item, seen));
53
+ }
54
+ const out: Record<string, unknown> = {};
55
+ for (const [key, field] of Object.entries(value as Record<string, unknown>)) {
56
+ out[key] = typeof field === 'string' && field !== '' && isSecretName(key) ? REDACTED : redact(field, seen);
57
+ }
58
+ return out;
59
+ }
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 = '2.2.0';
5
+ export const VERSION = '2.2.1';
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Unblocking clients the controller no longer treats as current, the blocked
3
+ * list, and deleting a client record (for orphaned blocks).
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import type { McpServer } from '@modelcontextprotocol/server';
8
+
9
+ import { ClientOperations, CLIENT_DOES_NOT_EXIST, normalizeMac } from '../../src/omadaClient/client.js';
10
+ import { OmadaApiError, RequestHandler } from '../../src/omadaClient/request.js';
11
+ import type { SiteOperations } from '../../src/omadaClient/site.js';
12
+ import type { OmadaClient } from '../../src/omadaClient/index.js';
13
+ import { registerDeleteClientTool } from '../../src/tools/omada/deleteClient.js';
14
+ import { registerListBlockedClientsTool } from '../../src/tools/omada/listBlockedClients.js';
15
+ import { Permission } from '../../src/permissions/index.js';
16
+
17
+ const buildPath = (p: string): string => `/openapi/v1/omadac1${p}`;
18
+ const MAC = '4C-1D-96-8D-37-C7';
19
+
20
+ function setup(knownClients: unknown[] = []) {
21
+ const request = {
22
+ post: vi.fn(),
23
+ request: vi.fn(),
24
+ fetchPaginated: vi.fn().mockResolvedValue(knownClients),
25
+ // Mirrors the real ensureSuccess: a typed error carrying the controller's errorCode.
26
+ ensureSuccess: vi.fn((response: { errorCode: number; msg?: string; result?: unknown }) => {
27
+ if (response.errorCode !== 0) throw new OmadaApiError(response.msg ?? 'Omada error', response.errorCode);
28
+ return response.result;
29
+ }),
30
+ };
31
+ const site = { resolveSiteId: vi.fn(() => 'site-1') } as unknown as SiteOperations;
32
+ return { request, ops: new ClientOperations(request as unknown as RequestHandler, site, buildPath) };
33
+ }
34
+
35
+ const doesNotExist = { errorCode: CLIENT_DOES_NOT_EXIST, msg: 'This client does not exist.' };
36
+
37
+ describe('normalizeMac', () => {
38
+ it('treats colon, dash and bare formats as the same MAC', () => {
39
+ expect(normalizeMac('4c:1d:96:8d:37:c7')).toBe(normalizeMac(MAC));
40
+ expect(normalizeMac('4c1d968d37c7')).toBe('4C1D968D37C7');
41
+ });
42
+ });
43
+
44
+ describe('ClientOperations.unblockClient', () => {
45
+ it('unblocks normally when the controller knows the client', async () => {
46
+ const { request, ops } = setup();
47
+ request.post.mockResolvedValue({ errorCode: 0 });
48
+
49
+ await expect(ops.unblockClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', blocked: false });
50
+ expect(request.fetchPaginated).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('reports success when the client is known and no longer blocked (e.g. unblocked in the web UI)', async () => {
54
+ const { request, ops } = setup([{ mac: '4c:1d:96:8d:37:c7', name: 'node-1271', block: false }]);
55
+ request.post.mockResolvedValue(doesNotExist);
56
+
57
+ await expect(ops.unblockClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', blocked: false });
58
+ expect(request.fetchPaginated).toHaveBeenCalledWith('/openapi/v1/omadac1/sites/site-1/insight/clients');
59
+ });
60
+
61
+ it('explains the controller limitation when the known client is still blocked', async () => {
62
+ const { request, ops } = setup([{ mac: MAC, name: 'node-1271', block: true }]);
63
+ request.post.mockResolvedValue(doesNotExist);
64
+
65
+ await expect(ops.unblockClient(MAC)).rejects.toThrow(/cannot unblock .* offline.*Known Clients.*omada_deleteClient/);
66
+ });
67
+
68
+ it('a known record without block state is not proof of an unblock', async () => {
69
+ const { request, ops } = setup([{ mac: MAC, name: 'node-1271' }]);
70
+ request.post.mockResolvedValue(doesNotExist);
71
+
72
+ await expect(ops.unblockClient(MAC)).rejects.toThrow(/cannot unblock/);
73
+ });
74
+
75
+ it('never reports success when the controller has no record at all (an orphaned block may remain)', async () => {
76
+ const { request, ops } = setup([{ mac: 'AA-BB-CC-DD-EE-FF', block: false }]);
77
+ request.post.mockResolvedValue(doesNotExist);
78
+
79
+ await expect(ops.unblockClient(MAC)).rejects.toThrow(/no record of .*orphaned block.*omada_deleteClient/);
80
+ });
81
+
82
+ it('other controller errors are passed through as before', async () => {
83
+ const { request, ops } = setup();
84
+ request.post.mockResolvedValue({ errorCode: -1, msg: 'General error' });
85
+
86
+ await expect(ops.unblockClient(MAC)).rejects.toThrow('General error');
87
+ expect(request.fetchPaginated).not.toHaveBeenCalled();
88
+ });
89
+ });
90
+
91
+ describe('unblockClient through the real request handler', () => {
92
+ afterEach(() => vi.unstubAllGlobals());
93
+
94
+ it('handles "client does not exist" sent with an HTTP error status, too', async () => {
95
+ const fetchMock = vi.fn(async (url: string) => {
96
+ if (url.includes('/unblock')) {
97
+ return new Response(JSON.stringify({ errorCode: CLIENT_DOES_NOT_EXIST, msg: 'This client does not exist.' }), { status: 400 });
98
+ }
99
+ // Known clients: present and no longer blocked.
100
+ return new Response(JSON.stringify({ errorCode: 0, result: { totalRows: 1, data: [{ mac: MAC, block: false }] } }), { status: 200 });
101
+ });
102
+ vi.stubGlobal('fetch', fetchMock);
103
+ const auth = { getAccessToken: vi.fn().mockResolvedValue('t'), clearToken: vi.fn() };
104
+ const request = new RequestHandler({ baseUrl: 'https://omada.test', strictSsl: true }, auth as never);
105
+ const site = { resolveSiteId: vi.fn(() => 'site-1') } as unknown as SiteOperations;
106
+ const ops = new ClientOperations(request, site, buildPath);
107
+
108
+ await expect(ops.unblockClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', blocked: false });
109
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/insight/clients'))).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe('ClientOperations.listBlockedClients / deleteClient', () => {
114
+ it('lists only blocked known clients, offline ones included', async () => {
115
+ const { ops } = setup([
116
+ { mac: MAC, name: 'node-1271', block: true, lastSeen: 1 },
117
+ { mac: 'AA-BB-CC-DD-EE-FF', name: 'tv', block: false },
118
+ { mac: '11-22-33-44-55-66', name: 'old' },
119
+ ]);
120
+
121
+ await expect(ops.listBlockedClients()).resolves.toEqual([{ mac: MAC, name: 'node-1271', block: true, lastSeen: 1 }]);
122
+ });
123
+
124
+ it('deletes the client record with DELETE /clients/{mac}, encoding the MAC', async () => {
125
+ const { request, ops } = setup();
126
+ request.request.mockResolvedValue({ errorCode: 0 });
127
+
128
+ await expect(ops.deleteClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', deleted: true });
129
+ expect(request.request).toHaveBeenCalledWith({ method: 'DELETE', url: `/openapi/v1/omadac1/sites/site-1/clients/${MAC}` });
130
+ });
131
+
132
+ it('surfaces a failed delete', async () => {
133
+ const { request, ops } = setup();
134
+ request.request.mockResolvedValue(doesNotExist);
135
+
136
+ await expect(ops.deleteClient(MAC)).rejects.toThrow('This client does not exist.');
137
+ });
138
+ });
139
+
140
+ describe('client block tools: permissions', () => {
141
+ const extra = (permissions: number) => ({ sessionId: 's', http: { authInfo: { extra: { permissions } } } });
142
+
143
+ function register() {
144
+ const handlers = new Map<string, (a: unknown, e: unknown) => Promise<{ content: { text: string }[] }>>();
145
+ const server = { registerTool: vi.fn((name, _c, h) => handlers.set(name, h)) } as unknown as McpServer;
146
+ const client = { deleteClient: vi.fn().mockResolvedValue({ deleted: true }), listBlockedClients: vi.fn().mockResolvedValue([]) };
147
+ registerDeleteClientTool(server, client as unknown as OmadaClient);
148
+ registerListBlockedClientsTool(server, client as unknown as OmadaClient);
149
+ return { handlers, client };
150
+ }
151
+
152
+ let tools: ReturnType<typeof register>;
153
+ beforeEach(() => {
154
+ tools = register();
155
+ });
156
+
157
+ it('omada_deleteClient needs CONFIGURE (CONTROL is not enough)', async () => {
158
+ const denied = await tools.handlers.get('omada_deleteClient')!({ clientMac: MAC }, extra(Permission.QUERY | Permission.CONTROL));
159
+ expect(JSON.parse(denied.content[0].text)).toMatchObject({ error: 'Permission denied' });
160
+ expect(tools.client.deleteClient).not.toHaveBeenCalled();
161
+
162
+ await tools.handlers.get('omada_deleteClient')!({ clientMac: MAC }, extra(Permission.CONFIGURE));
163
+ expect(tools.client.deleteClient).toHaveBeenCalledWith(MAC, undefined);
164
+ });
165
+
166
+ it('omada_listBlockedClients needs only QUERY', async () => {
167
+ await tools.handlers.get('omada_listBlockedClients')!({}, extra(Permission.QUERY));
168
+ expect(tools.client.listBlockedClients).toHaveBeenCalled();
169
+ });
170
+ });
@@ -79,11 +79,11 @@ describe('Omada graph tools - registration', () => {
79
79
  expect(server.handlers.has('omada_read')).toBe(true);
80
80
  });
81
81
 
82
- it('graph mode registers browse + read + 8 typed writes, and NO typed getters', () => {
82
+ it('graph mode registers browse + read + 9 typed writes, and NO typed getters', () => {
83
83
  const server = createMockServer();
84
84
  const client = createMockClient();
85
85
  const count = registerOmadaTools(server, client, 'graph');
86
- expect(count).toBe(10);
86
+ expect(count).toBe(11);
87
87
  const names = [...server.handlers.keys()];
88
88
  expect(names).toContain('omada_browse');
89
89
  expect(names).toContain('omada_read');
@@ -93,10 +93,12 @@ describe('Omada graph tools - registration', () => {
93
93
  expect(names).toContain('omada_cyclePoePort');
94
94
  expect(names).toContain('omada_setSsidEnabled');
95
95
  expect(names).toContain('omada_setSiteNtpServers');
96
+ expect(names).toContain('omada_deleteClient');
96
97
  // Typed read getters must NOT be present in graph mode.
97
98
  expect(names).not.toContain('omada_listSites');
98
99
  expect(names).not.toContain('omada_getFirewallSetting');
99
100
  expect(names).not.toContain('omada_getSiteNtpStatus');
101
+ expect(names).not.toContain('omada_listBlockedClients');
100
102
  });
101
103
 
102
104
  it('eager mode (default) registers the typed getters, not the graph tools', () => {
@@ -310,6 +312,15 @@ describe('omada_read', () => {
310
312
  expect(client.getSiteNtpStatus).not.toHaveBeenCalled();
311
313
  });
312
314
 
315
+ it('reads /clients/known as one page of known clients (with block state)', async () => {
316
+ (client.readResource as ReturnType<typeof vi.fn>).mockResolvedValue({ totalRows: 1, data: [{ mac: 'AA', block: true }] });
317
+ await read({ path: '/clients/known', page: 3, pageSize: 20, siteId: 'site1' }, readonlyExtra);
318
+ expect(client.readResource).toHaveBeenCalledTimes(1);
319
+ expect(client.readResource).toHaveBeenCalledWith(
320
+ expect.objectContaining({ pathTemplate: '/sites/{siteId}/insight/clients', siteId: 'site1', paginated: true, page: 3, pageSize: 20 })
321
+ );
322
+ });
323
+
313
324
  it('enforces per-path permission (fail closed) for under-privileged callers', async () => {
314
325
  const { isError, data } = parseResult(await read({ path: '/sites' }, noPermsExtra));
315
326
  expect(isError).toBe(true);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Secrets returned by Home Assistant / Omada never reach a tool response.
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import type { McpServer } from '@modelcontextprotocol/server';
7
+
8
+ import { redactSecrets, REDACTED } from '../../src/utils/redact.js';
9
+ import { toToolResult } from '../../src/tools/common.js';
10
+ import { registerOmadaGraphTools } from '../../src/tools/omada/graph.js';
11
+ import type { OmadaClient } from '../../src/omadaClient/index.js';
12
+
13
+ /** Shape of an Omada SSID detail (GET .../wlans/{wlanId}/ssids/{ssidId}). */
14
+ const ssidDetail = {
15
+ ssidId: 'node-s55',
16
+ name: 'node-s55',
17
+ security: 3,
18
+ hidePwd: true,
19
+ pskSetting: { versionPsk: 2, encryptionPsk: 3, gikRekeyPskEnable: false, securityKey: 'HouseWifiPassw0rd!' },
20
+ macFilterEnable: false,
21
+ };
22
+
23
+ describe('redactSecrets', () => {
24
+ it('redacts the Wi-Fi password but keeps flags and numbers with similar names', () => {
25
+ const out = redactSecrets(ssidDetail);
26
+
27
+ expect(out.pskSetting.securityKey).toBe(REDACTED);
28
+ expect(out.pskSetting.versionPsk).toBe(2);
29
+ expect(out.pskSetting.encryptionPsk).toBe(3);
30
+ expect(out.hidePwd).toBe(true);
31
+ expect(out.name).toBe('node-s55');
32
+ expect(JSON.stringify(out)).not.toContain('HouseWifiPassw0rd!');
33
+ });
34
+
35
+ it.each([
36
+ ['RADIUS profile', { authServer: [{ ip: '192.168.0.2', port: 1812, secret: 'radius-s3cret' }], accountingServerPwd: 'acct-pw', coaPassword: 'coa-pw', radiusPwd: 'rp' }],
37
+ ['PPSK profile', { name: 'kids', ppsk: [{ name: 'tablet', password: 'ppsk-key-1', vlanId: 20 }] }],
38
+ ['VPN', { preSharedKey: 'ipsec-psk', presharedKey: 'wg-psk', privateKey: 'wg-private', publicKey: 'wg-public' }],
39
+ ['SNMP', { snmpV1V2CEnable: true, communityString: 'snmp-community' }],
40
+ ['portal and hotspot', { password: 'portal-pw', authCode: 'auth-code-123', simpleKey: 'sk', md5Key: 'mk' }],
41
+ ['SSID variants', { psk: 'override-psk', ssidPassword: 'ssid-pw', bridgeSsidPassword: 'bridge-pw', overrideSsidPassword: 'ov-pw' }],
42
+ ['VRRP and certificates', { key: 'vrrp-key', keyPassword: 'ks-pw', trustPassword: 'trust-pw' }],
43
+ ['webhooks and tokens', { shardedSecret: 'hook-secret', authToken: 'twilio-token', accessToken: 'at', token: 'user-token' }],
44
+ ])('redacts every secret in a %s', (_name, input) => {
45
+ const text = JSON.stringify(redactSecrets(input));
46
+
47
+ for (const secret of ['radius-s3cret', 'acct-pw', 'coa-pw', 'ppsk-key-1', 'ipsec-psk', 'wg-psk', 'wg-private', 'snmp-community', 'portal-pw', 'auth-code-123', 'override-psk', 'ssid-pw', 'bridge-pw', 'ov-pw', 'vrrp-key', 'ks-pw', 'trust-pw', 'hook-secret', 'twilio-token', 'user-token']) {
48
+ expect(text).not.toContain(secret);
49
+ }
50
+ });
51
+
52
+ it('keeps public, non-secret values (public keys, ids, names)', () => {
53
+ const out = redactSecrets({ publicKey: 'wg-public', keyId: 'k1', searchKey: 'abc', ppskProfileId: 'p1' });
54
+ expect(out).toEqual({ publicKey: 'wg-public', keyId: 'k1', searchKey: 'abc', ppskProfileId: 'p1' });
55
+ });
56
+
57
+ it('masks tokens inside URLs (Home Assistant camera image)', () => {
58
+ const out = redactSecrets({
59
+ entity_picture: '/api/camera_proxy/camera.door?token=0123456789abcdef&width=640',
60
+ attributes: { access_token: 'cam-token' },
61
+ });
62
+ expect(out.entity_picture).toBe(`/api/camera_proxy/camera.door?token=${REDACTED}&width=640`);
63
+ expect(out.attributes.access_token).toBe(REDACTED);
64
+ });
65
+
66
+ it('leaves empty secrets empty, so "is it set?" stays answerable', () => {
67
+ expect(redactSecrets({ password: '', securityKey: 'x' })).toEqual({ password: '', securityKey: REDACTED });
68
+ });
69
+
70
+ it('does not modify its input', () => {
71
+ const input = structuredClone(ssidDetail);
72
+ redactSecrets(input);
73
+ expect(input).toEqual(ssidDetail);
74
+ });
75
+
76
+ it('handles arrays, nulls and plain strings', () => {
77
+ expect(redactSecrets([{ password: 'p' }, null, 3])).toEqual([{ password: REDACTED }, null, 3]);
78
+ expect(redactSecrets('see /x?token=abc')).toBe(`see /x?token=${REDACTED}`);
79
+ expect(redactSecrets(null)).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('tool responses are redacted', () => {
84
+ it('toToolResult never carries a secret', () => {
85
+ const result = toToolResult(ssidDetail);
86
+ expect(result.content[0].text).not.toContain('HouseWifiPassw0rd!');
87
+ expect(result.content[0].text).toContain(REDACTED);
88
+ });
89
+
90
+ it('omada_read /wifi/ssids (SSID detail) does not return the Wi-Fi password', async () => {
91
+ const handlers = new Map<string, (args: unknown, extra: unknown) => Promise<{ content: { text: string }[] }>>();
92
+ const server = { registerTool: vi.fn((name, _c, h) => handlers.set(name, h)) } as unknown as McpServer;
93
+ const client = { getSsidDetail: vi.fn().mockResolvedValue(ssidDetail), getSsidList: vi.fn() } as unknown as OmadaClient;
94
+ registerOmadaGraphTools(server, client);
95
+
96
+ const result = await handlers.get('omada_read')!(
97
+ { path: '/wifi/ssids', params: { wlanId: 'w1', ssidId: 'node-s55' } },
98
+ { sessionId: 's', http: { authInfo: { extra: { permissions: 0xff } } } }
99
+ );
100
+
101
+ expect(client.getSsidDetail).toHaveBeenCalled();
102
+ expect(result.content[0].text).not.toContain('HouseWifiPassw0rd!');
103
+ expect(JSON.parse(result.content[0].text).pskSetting.securityKey).toBe(REDACTED);
104
+ });
105
+ });