@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
package/src/config.ts CHANGED
@@ -55,6 +55,10 @@ const envSchema = z
55
55
  logFormat: z.enum(['plain', 'json', 'gcp-json']).optional().default('plain'),
56
56
  useHttp: createBooleanStringSchema(false),
57
57
  stateful: createBooleanStringSchema(false),
58
+ // Tool registration strategy. `eager` (default) registers every typed tool;
59
+ // `graph` registers the Omada resource-graph reads (omada_browse/omada_read)
60
+ // plus typed writes, shrinking the tool-schema budget for low-context models.
61
+ toolRegistrationMode: z.enum(['eager', 'graph']).optional().default('eager'),
58
62
 
59
63
  // MCP Server HTTP Configuration
60
64
  httpPort: numericStringSchema,
@@ -200,6 +204,7 @@ export interface EnvironmentConfig {
200
204
  logFormat: 'plain' | 'json' | 'gcp-json';
201
205
  useHttp: boolean;
202
206
  stateful: boolean;
207
+ toolRegistrationMode: 'eager' | 'graph';
203
208
 
204
209
  // MCP Server HTTP Configuration
205
210
  httpPort?: number;
@@ -269,6 +274,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
269
274
  logFormat: env.MCP_SERVER_LOG_FORMAT,
270
275
  useHttp: env.MCP_SERVER_USE_HTTP,
271
276
  stateful: env.MCP_SERVER_STATEFUL,
277
+ toolRegistrationMode: env.MCP_TOOL_REGISTRATION_MODE,
272
278
 
273
279
  // MCP Server HTTP Configuration
274
280
  httpPort: env.MCP_HTTP_PORT,
@@ -362,6 +368,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
362
368
  logFormat: parsed.data.logFormat,
363
369
  useHttp: parsed.data.useHttp,
364
370
  stateful: parsed.data.stateful,
371
+ toolRegistrationMode: parsed.data.toolRegistrationMode,
365
372
 
366
373
  // MCP Server HTTP Configuration
367
374
  httpPort,
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { loadConfig } from './config.js';
9
9
  import { HaClient } from './haClient/index.js';
10
10
  import { LocalAIClient } from './localAI/index.js';
11
11
  import { OmadaClient } from './omadaClient/index.js';
12
+ import { checkConfiguredSite } from './omadaClient/site.js';
12
13
  import { startHttpServer } from './server/http.js';
13
14
  import { startStdioServer } from './server/stdio.js';
14
15
  import { initLogger, logger } from './utils/logger.js';
@@ -71,7 +72,18 @@ async function main(): Promise<void> {
71
72
 
72
73
  // Test Omada connection by listing sites
73
74
  const sites = await omadaClient.listSites();
74
- logger.info('Connected to Omada controller', { siteCount: sites.length });
75
+
76
+ // Omada is the source of truth: a configured OMADA_SITE_ID that isn't
77
+ // on the controller is a deployment bug (drifted site id), not a
78
+ // transient outage. Fail the Omada plugin loudly — HA and AI keep
79
+ // working — instead of silently scoping every read to a dead site.
80
+ const siteError = checkConfiguredSite(config.siteId, sites);
81
+ if (siteError) {
82
+ logger.error(`Omada plugin disabled - invalid OMADA_SITE_ID. ${siteError}`);
83
+ omadaClient = undefined;
84
+ } else {
85
+ logger.info('Connected to Omada controller', { siteCount: sites.length });
86
+ }
75
87
  } catch (error) {
76
88
  logger.warn('Omada controller not available - Omada tools will be disabled', {
77
89
  error: error instanceof Error ? error.message : String(error),
@@ -115,7 +127,12 @@ async function main(): Promise<void> {
115
127
  if (config.useHttp) {
116
128
  await startHttpServer({ haClient, omadaClient, aiClient, config });
117
129
  } else {
118
- await startStdioServer({ haClient, omadaClient, aiClient });
130
+ await startStdioServer({
131
+ haClient,
132
+ omadaClient,
133
+ aiClient,
134
+ toolRegistrationMode: config.toolRegistrationMode,
135
+ });
119
136
  }
120
137
  } catch (error) {
121
138
  const message = error instanceof Error ? error.message : String(error);
@@ -9,6 +9,7 @@ import type {
9
9
  GetThreatListOptions,
10
10
  ListClientsPastConnectionsOptions,
11
11
  OmadaClientInfo,
12
+ OmadaApiResponse,
12
13
  OmadaDeviceInfo,
13
14
  OmadaDeviceStats,
14
15
  OmadaSiteSummary,
@@ -204,6 +205,70 @@ export class OmadaClient {
204
205
  return await this.request.request<T>(config);
205
206
  }
206
207
 
208
+ /**
209
+ * Read an arbitrary Omada resource described by a path template.
210
+ *
211
+ * This is the single client primitive behind the resource-graph `omada_read`
212
+ * tool: "many endpoints, zero new client methods". A path template may contain
213
+ * `{placeholder}` segments (each substituted from `siteId`/`pathParams` and
214
+ * URL-encoded), and may be site-scoped (the common case) or controller-global.
215
+ *
216
+ * Pagination is deliberately SINGLE-page: for paginated resources it forwards
217
+ * one page/pageSize to a single GET rather than walking every page (no giant
218
+ * payloads). It returns the unwrapped `result` (errorCode !== 0 throws via
219
+ * `ensureSuccess`).
220
+ *
221
+ * @param options.pathTemplate e.g. '/sites/{siteId}/gateways/{gatewayMac}/wan-status'
222
+ * @param options.siteScoped when true (default) `{siteId}` is resolved from siteId/default
223
+ * @param options.paginated when true, page/pageSize are sent as query params
224
+ * @param options.pathParams values for non-site `{placeholder}` segments
225
+ * @param options.query extra query parameters
226
+ */
227
+ public async readResource(options: {
228
+ pathTemplate: string;
229
+ siteScoped?: boolean;
230
+ paginated?: boolean;
231
+ siteId?: string;
232
+ pathParams?: Record<string, string>;
233
+ query?: Record<string, unknown>;
234
+ page?: number;
235
+ pageSize?: number;
236
+ }): Promise<unknown> {
237
+ const {
238
+ pathTemplate,
239
+ siteScoped = true,
240
+ paginated = false,
241
+ siteId,
242
+ pathParams = {},
243
+ query = {},
244
+ page,
245
+ pageSize,
246
+ } = options;
247
+
248
+ const substitutions: Record<string, string> = { ...pathParams };
249
+ if (siteScoped || pathTemplate.includes('{siteId}')) {
250
+ substitutions.siteId = this.siteOps.resolveSiteId(siteId);
251
+ }
252
+
253
+ const relativePath = pathTemplate.replace(/\{(\w+)\}/g, (_match, key: string) => {
254
+ const value = substitutions[key];
255
+ if (value === undefined || value === '') {
256
+ throw new Error(`Missing required path parameter '${key}' for resource '${pathTemplate}'`);
257
+ }
258
+ return encodeURIComponent(value);
259
+ });
260
+
261
+ const effectiveQuery: Record<string, unknown> = { ...query };
262
+ if (paginated) {
263
+ effectiveQuery.page = page ?? 1;
264
+ effectiveQuery.pageSize = pageSize ?? 50;
265
+ }
266
+
267
+ const fullPath = this.buildOmadaPath(relativePath);
268
+ const response = await this.request.get<OmadaApiResponse<unknown>>(fullPath, effectiveQuery);
269
+ return this.request.ensureSuccess(response);
270
+ }
271
+
207
272
  /**
208
273
  * Build a full Omada API path from a relative path.
209
274
  * @param relativePath - The relative path to append to the base API path
@@ -156,8 +156,21 @@ export class RequestHandler {
156
156
  url: config.url,
157
157
  status: response.status,
158
158
  statusText: response.statusText,
159
+ errorCode,
160
+ message: errorMsg,
159
161
  });
160
- throw new Error(`Omada API request failed: ${response.status} ${response.statusText}`);
162
+ // Surface the controller's own structured errorCode/msg when present
163
+ // (safe diagnostic data — the controller's description, not an internal
164
+ // hostname/header) so a rejected request explains itself instead of
165
+ // collapsing to a bare HTTP status like "400 ".
166
+ const parts = [`${response.status} ${response.statusText}`.trim()];
167
+ if (errorMsg) {
168
+ parts.push(`- ${errorMsg}`);
169
+ }
170
+ if (errorCode !== undefined && errorCode !== 0) {
171
+ parts.push(`(errorCode ${errorCode})`);
172
+ }
173
+ throw new Error(`Omada API request failed: ${parts.join(' ')}`);
161
174
  }
162
175
 
163
176
  return responseData;
@@ -2,6 +2,35 @@ import type { OmadaSiteSummary } from '../types/index.js';
2
2
 
3
3
  import type { RequestHandler } from './request.js';
4
4
 
5
+ /**
6
+ * Validate a configured default site (`OMADA_SITE_ID`) against the controller's
7
+ * live site list.
8
+ *
9
+ * The Omada controller is the source of truth; `OMADA_SITE_ID` is only a
10
+ * selector/filter against it. A value that isn't on the controller is a
11
+ * deployment/configuration bug (e.g. the site id drifted after a controller
12
+ * migration, exactly like `OMADA_OMADAC_ID` does) — not a transient outage. If
13
+ * left unvalidated it silently scopes every site-scoped read to a dead site and
14
+ * surfaces later as a mysterious "user does not have permissions to access this
15
+ * site" error.
16
+ *
17
+ * @returns an actionable error message if `OMADA_SITE_ID` is set but absent from
18
+ * `sites`; `null` if it is unset (no default) or valid.
19
+ */
20
+ export function checkConfiguredSite(
21
+ configuredSiteId: string | undefined,
22
+ sites: OmadaSiteSummary[]
23
+ ): string | null {
24
+ if (!configuredSiteId) {
25
+ return null;
26
+ }
27
+ if (sites.some((s) => s.siteId === configuredSiteId)) {
28
+ return null;
29
+ }
30
+ const available = sites.map((s) => `${s.name} (${s.siteId})`).join(', ') || '(controller returned no sites)';
31
+ return `OMADA_SITE_ID="${configuredSiteId}" was not found on the controller. Set OMADA_SITE_ID to one of: ${available}.`;
32
+ }
33
+
5
34
  /**
6
35
  * Site-related operations for the Omada API.
7
36
  */
@@ -38,17 +38,36 @@ export interface PermissionsConfig {
38
38
  defaultRole?: RoleName;
39
39
  }
40
40
 
41
+ /**
42
+ * Resolve a role name to its permission mask, case-insensitively.
43
+ *
44
+ * Config (and JSON) commonly use lowercase role names ("operator") while the
45
+ * `Role` map is keyed uppercase ("OPERATOR"). Normalize here so BOTH the per-user
46
+ * role and the `defaultRole` fall-back resolve consistently. An unknown/undefined
47
+ * name fails closed to `Role.NONE` (never `undefined`, which downstream coerces to
48
+ * a zero mask anyway but is easy to mishandle).
49
+ */
50
+ function resolveRoleName(name: string | undefined): number {
51
+ if (!name) {
52
+ return Role.NONE;
53
+ }
54
+ const key = name.toUpperCase() as RoleName;
55
+ return Role[key] ?? Role.NONE;
56
+ }
57
+
41
58
  /**
42
59
  * Get permission mask for a user by their JWT sub claim
43
60
  */
44
61
  export function getUserPermissions(sub: string | undefined, config: PermissionsConfig): number {
62
+ const fallback = resolveRoleName(config.defaultRole);
63
+
45
64
  if (!sub) {
46
- return config.defaultRole ? Role[config.defaultRole] : Role.NONE;
65
+ return fallback;
47
66
  }
48
67
 
49
68
  const user = config.users.find((u) => u.sub === sub);
50
69
  if (!user) {
51
- return config.defaultRole ? Role[config.defaultRole] : Role.NONE;
70
+ return fallback;
52
71
  }
53
72
 
54
73
  // Direct mask takes precedence over role
@@ -56,9 +75,7 @@ export function getUserPermissions(sub: string | undefined, config: PermissionsC
56
75
  return user.mask;
57
76
  }
58
77
 
59
- // Convert role to uppercase for lookup (config may have lowercase)
60
- const roleKey = user.role?.toUpperCase() as RoleName | undefined;
61
- return roleKey && Role[roleKey] !== undefined ? Role[roleKey] : Role.NONE;
78
+ return resolveRoleName(user.role);
62
79
  }
63
80
 
64
81
  /**
@@ -8,6 +8,7 @@ import type { HaClient } from '../haClient/index.js';
8
8
  import type { LocalAIClient } from '../localAI/index.js';
9
9
  import type { OmadaClient } from '../omadaClient/index.js';
10
10
  import { registerAllTools } from '../tools/index.js';
11
+ import type { OmadaRegistrationMode } from '../tools/omada/index.js';
11
12
  import { registerAllResources } from '../resources/index.js';
12
13
  import { generateInstructions } from './instructions.js';
13
14
  import { logger } from '../utils/logger.js';
@@ -17,10 +18,12 @@ export interface CreateServerOptions {
17
18
  haClient?: HaClient;
18
19
  omadaClient?: OmadaClient;
19
20
  aiClient?: LocalAIClient;
21
+ /** Tool registration strategy for the Omada plugin (default 'eager'). */
22
+ toolRegistrationMode?: OmadaRegistrationMode;
20
23
  }
21
24
 
22
25
  export function createServer(options: CreateServerOptions): McpServer {
23
- const { haClient, omadaClient, aiClient } = options;
26
+ const { haClient, omadaClient, aiClient, toolRegistrationMode } = options;
24
27
  logger.debug('Creating MCP server instance');
25
28
 
26
29
  // Generate instructions based on enabled plugins
@@ -48,6 +51,7 @@ export function createServer(options: CreateServerOptions): McpServer {
48
51
  haClient,
49
52
  omadaClient,
50
53
  aiClient,
54
+ toolRegistrationMode,
51
55
  });
52
56
 
53
57
  // Register Home Assistant resources (if HA client provided)
@@ -9,12 +9,15 @@ import type { LocalAIClient } from '../localAI/index.js';
9
9
  import type { OmadaClient } from '../omadaClient/index.js';
10
10
  import { logger } from '../utils/logger.js';
11
11
  import { setLocalFullTrust } from '../tools/common.js';
12
+ import type { OmadaRegistrationMode } from '../tools/omada/index.js';
12
13
  import { createServer } from './common.js';
13
14
 
14
15
  export interface StdioServerOptions {
15
16
  haClient?: HaClient;
16
17
  omadaClient?: OmadaClient;
17
18
  aiClient?: LocalAIClient;
19
+ /** Tool registration strategy for the Omada plugin (default 'eager'). */
20
+ toolRegistrationMode?: OmadaRegistrationMode;
18
21
  }
19
22
 
20
23
  export async function startStdioServer(options: StdioServerOptions): Promise<void> {
@@ -94,7 +94,12 @@ export function buildTransportSecurityOptions(config: EnvironmentConfig): Transp
94
94
  */
95
95
  export function createStreamTransport(options: StreamTransportOptions): StreamTransportState {
96
96
  const { haClient, omadaClient, aiClient, config } = options;
97
- const mcpServer = createServer({ haClient, omadaClient, aiClient });
97
+ const mcpServer = createServer({
98
+ haClient,
99
+ omadaClient,
100
+ aiClient,
101
+ toolRegistrationMode: config.toolRegistrationMode,
102
+ });
98
103
 
99
104
  const enableStatefulSessions = config.stateful;
100
105
  const sessionIdGenerator = enableStatefulSessions ? () => randomUUID() : undefined;
@@ -36,6 +36,21 @@ export function setLocalFullTrust(enabled: boolean): void {
36
36
  */
37
37
  export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
38
38
 
39
+ /**
40
+ * Resolve the caller's permission mask from the MCP SDK auth context.
41
+ *
42
+ * L3: fail closed when the mask is absent — only the trusted-local stdio path
43
+ * (setLocalFullTrust) grants full permissions (0xFF) without an explicit mask.
44
+ * HTTP requests always carry a mask (bearer-derived, or 0xFF for loopback `none`).
45
+ *
46
+ * Exposed so tools that gate per-resource (e.g. the Omada resource-graph
47
+ * `omada_read`/`omada_browse`) can apply the same extraction + fail-closed
48
+ * semantics as `wrapToolHandler`, instead of re-reading `authInfo` ad hoc.
49
+ */
50
+ export function getCallerPermissions(extra: ToolExtra): number {
51
+ return (extra.authInfo?.extra?.permissions as number | undefined) ?? (localFullTrust ? 0xFF : 0);
52
+ }
53
+
39
54
  /**
40
55
  * Convert any value to a CallToolResult
41
56
  */
@@ -76,8 +91,7 @@ export function wrapToolHandler<T>(
76
91
  // Extract permissions from MCP SDK authInfo.extra.permissions.
77
92
  // L3: fail closed when the mask is absent — only the trusted-local stdio path
78
93
  // (setLocalFullTrust) grants full permissions without an explicit mask.
79
- const userPermissions =
80
- (extra.authInfo?.extra?.permissions as number | undefined) ?? (localFullTrust ? 0xFF : 0);
94
+ const userPermissions = getCallerPermissions(extra);
81
95
 
82
96
  // Check permissions if required
83
97
  if (requiredPermission !== undefined && !hasPermission(userPermissions, requiredPermission)) {
@@ -10,7 +10,7 @@ import { logger } from '../utils/logger.js';
10
10
 
11
11
  // Tool registration functions by domain
12
12
  import { registerHomeAssistantTools } from './homeassistant/index.js';
13
- import { registerOmadaTools } from './omada/index.js';
13
+ import { registerOmadaTools, type OmadaRegistrationMode } from './omada/index.js';
14
14
  import { registerAITools } from './ai/index.js';
15
15
 
16
16
  export interface RegisterToolsOptions {
@@ -18,13 +18,15 @@ export interface RegisterToolsOptions {
18
18
  haClient?: HaClient;
19
19
  omadaClient?: OmadaClient;
20
20
  aiClient?: LocalAIClient;
21
+ /** Tool registration strategy for the Omada plugin (default 'eager'). */
22
+ toolRegistrationMode?: OmadaRegistrationMode;
21
23
  }
22
24
 
23
25
  /**
24
26
  * Register all available tools based on configured clients
25
27
  */
26
28
  export function registerAllTools(options: RegisterToolsOptions): void {
27
- const { server, haClient, omadaClient, aiClient } = options;
29
+ const { server, haClient, omadaClient, aiClient, toolRegistrationMode } = options;
28
30
  logger.debug('Registering all tools');
29
31
 
30
32
  let totalTools = 0;
@@ -37,7 +39,7 @@ export function registerAllTools(options: RegisterToolsOptions): void {
37
39
 
38
40
  // Omada tools (if client provided)
39
41
  if (omadaClient) {
40
- const omadaToolCount = registerOmadaTools(server, omadaClient);
42
+ const omadaToolCount = registerOmadaTools(server, omadaClient, toolRegistrationMode);
41
43
  totalTools += omadaToolCount;
42
44
  }
43
45
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Omada resource-graph tools: `omada_browse` + `omada_read`.
3
+ *
4
+ * These two tools replace dozens of per-endpoint getters when
5
+ * `MCP_TOOL_REGISTRATION_MODE=graph`. The model navigates a discoverable
6
+ * namespace (browse → read) instead of selecting from a flat list of ~25 tool
7
+ * schemas, which keeps the tool-schema budget small for low-context models while
8
+ * still exposing every read endpoint.
9
+ *
10
+ * Authorization mirrors `wrapToolHandler`:
11
+ * - `omada_browse` requires QUERY to use, and additionally filters the children
12
+ * it advertises by the caller's mask (so sensitive subtrees stay invisible).
13
+ * - `omada_read` declares NO static tool permission; instead it enforces the
14
+ * per-path permission from the manifest, so each path can require a different
15
+ * bit. Unknown / container / under-privileged paths fail closed.
16
+ */
17
+
18
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19
+ import { z } from 'zod';
20
+ import type { OmadaClient } from '../../omadaClient/index.js';
21
+ import { hasPermission, getPermissionNames } from '../../permissions/index.js';
22
+ import { toToolResult, wrapToolHandler, getCallerPermissions, Permission, type ToolExtra } from '../common.js';
23
+ import { childrenOf, getResourceNode, normalizePath, type ReadArgs, type ResourceNode } from './namespace.js';
24
+
25
+ /** Compact, model-friendly metadata for a node (used in browse output). */
26
+ function nodeSummary(node: ResourceNode): Record<string, unknown> {
27
+ return {
28
+ path: node.path,
29
+ kind: node.kind,
30
+ permission: getPermissionNames(node.permission).join(',') || 'NONE',
31
+ description: node.description,
32
+ readable: node.fetch !== undefined,
33
+ ...(node.estimatedSize ? { estimatedSize: node.estimatedSize } : {}),
34
+ ...(node.paginated ? { supportsPagination: true, defaultPageSize: node.defaultPageSize ?? 50 } : {}),
35
+ ...(node.params && node.params.length > 0 ? { params: node.params } : {}),
36
+ };
37
+ }
38
+
39
+ const browseSchema = z.object({
40
+ path: z
41
+ .string()
42
+ .default('/')
43
+ .describe('Resource path to explore (default "/"). e.g. "/", "/gateway", "/wifi", "/network".'),
44
+ });
45
+
46
+ const readSchema = z.object({
47
+ path: z.string().describe('Resource path discovered via omada_browse, e.g. "/gateway/wan", "/clients", "/events".'),
48
+ siteId: z.string().min(1).optional().describe('Site ID (optional; uses the default site if not set).'),
49
+ id: z.string().min(1).optional().describe('Look up a single member of a collection by id/MAC (where supported).'),
50
+ params: z
51
+ .record(z.string())
52
+ .optional()
53
+ .describe('Path parameters some resources require (see omada_browse "params"), e.g. {"gatewayMac":"AA-BB-CC-DD-EE-FF"}.'),
54
+ page: z.number().int().min(1).optional().describe('Page number for paginated resources (e.g. "/events").'),
55
+ pageSize: z.number().int().min(1).max(200).optional().describe('Page size for paginated resources (max 200).'),
56
+ });
57
+
58
+ /**
59
+ * Register the resource-graph tools. Returns the number of tools registered (2).
60
+ */
61
+ export function registerOmadaGraphTools(server: McpServer, client: OmadaClient): number {
62
+ server.registerTool(
63
+ 'omada_browse',
64
+ {
65
+ description:
66
+ 'Discover the Omada network resource graph. Returns the children and metadata at a path (resource TYPES, not ' +
67
+ 'instances — no MAC enumeration), so navigate from "/" downward, then call omada_read at a readable node to fetch ' +
68
+ 'data. This replaces dozens of individual getters with one discoverable namespace.',
69
+ inputSchema: browseSchema.shape,
70
+ },
71
+ wrapToolHandler(
72
+ 'omada_browse',
73
+ async ({ path }: z.infer<typeof browseSchema>, extra: ToolExtra): Promise<ReturnType<typeof toToolResult>> => {
74
+ const norm = normalizePath(path);
75
+ const node = getResourceNode(norm);
76
+ if (!node) {
77
+ return toToolResult(
78
+ { error: 'Unknown path', message: `No Omada resource at '${norm}'. Start at '/' and browse downward.` },
79
+ true
80
+ );
81
+ }
82
+ const mask = getCallerPermissions(extra);
83
+ const children = childrenOf(norm)
84
+ .filter((child) => hasPermission(mask, child.permission))
85
+ .map(nodeSummary);
86
+ return toToolResult({
87
+ path: norm,
88
+ kind: node.kind,
89
+ description: node.description,
90
+ readable: node.fetch !== undefined,
91
+ ...(node.params && node.params.length > 0 ? { params: node.params } : {}),
92
+ ...(node.paginated ? { supportsPagination: true, defaultPageSize: node.defaultPageSize ?? 50 } : {}),
93
+ children,
94
+ });
95
+ },
96
+ Permission.QUERY
97
+ )
98
+ );
99
+
100
+ server.registerTool(
101
+ 'omada_read',
102
+ {
103
+ description:
104
+ 'Read data from an Omada resource path discovered via omada_browse. Handles single resources (e.g. "/gateway/wan"), ' +
105
+ 'collections (e.g. "/clients", optionally id=<MAC> for one member), and paginated logs (e.g. "/events" with ' +
106
+ 'page/pageSize). Authorization is enforced per-path.',
107
+ inputSchema: readSchema.shape,
108
+ },
109
+ // No static permission: each path declares its own, enforced below (fail-closed).
110
+ wrapToolHandler(
111
+ 'omada_read',
112
+ async (args: z.infer<typeof readSchema>, extra: ToolExtra): Promise<ReturnType<typeof toToolResult>> => {
113
+ const norm = normalizePath(args.path);
114
+ const node = getResourceNode(norm);
115
+ if (!node) {
116
+ return toToolResult(
117
+ { error: 'Unknown path', message: `No Omada resource at '${norm}'. Use omada_browse to discover valid paths.` },
118
+ true
119
+ );
120
+ }
121
+ if (!node.fetch) {
122
+ return toToolResult(
123
+ { error: 'Not readable', message: `'${norm}' is a container. Use omada_browse to list its children.` },
124
+ true
125
+ );
126
+ }
127
+
128
+ const mask = getCallerPermissions(extra);
129
+ if (!hasPermission(mask, node.permission)) {
130
+ const required = getPermissionNames(node.permission);
131
+ const has = getPermissionNames(mask);
132
+ return toToolResult(
133
+ { error: 'Permission denied', message: `Reading '${norm}' requires permission: ${required.join(', ')}`, required, has },
134
+ true
135
+ );
136
+ }
137
+
138
+ // Validate required path parameters before touching the controller.
139
+ const missing = (node.params ?? [])
140
+ .filter((p) => p.required && !args.params?.[p.name])
141
+ .map((p) => p.name);
142
+ if (missing.length > 0) {
143
+ return toToolResult(
144
+ { error: 'Missing parameters', message: `'${norm}' requires: ${missing.join(', ')}. Pass them in "params".`, missing },
145
+ true
146
+ );
147
+ }
148
+
149
+ const readArgs: ReadArgs = {
150
+ siteId: args.siteId,
151
+ id: args.id,
152
+ params: args.params,
153
+ page: args.page,
154
+ pageSize: args.pageSize,
155
+ };
156
+ return toToolResult(await node.fetch(client, readArgs));
157
+ }
158
+ )
159
+ );
160
+
161
+ return 2;
162
+ }
@@ -7,6 +7,7 @@ import type { OmadaClient } from '../../omadaClient/index.js';
7
7
  import { logger } from '../../utils/logger.js';
8
8
 
9
9
  import { registerBlockClientTool } from './blockClient.js';
10
+ import { registerOmadaGraphTools } from './graph.js';
10
11
  import { registerDisableClientRateLimitTool } from './disableClientRateLimit.js';
11
12
  import { registerGetClientTool } from './getClient.js';
12
13
  import { registerGetDeviceTool } from './getDevice.js';
@@ -33,8 +34,25 @@ import { registerSetClientRateLimitTool } from './setClientRateLimit.js';
33
34
  import { registerSetClientRateLimitProfileTool } from './setClientRateLimitProfile.js';
34
35
  import { registerUnblockClientTool } from './unblockClient.js';
35
36
 
36
- export function registerOmadaTools(server: McpServer, client: OmadaClient): number {
37
- logger.debug('Registering Omada tools');
37
+ /**
38
+ * Tool registration strategy for the Omada plugin.
39
+ * - `eager` (default): register every typed Omada tool (~25). Backwards-compatible.
40
+ * - `graph`: register only the resource-graph reads (`omada_browse` + `omada_read`)
41
+ * plus the typed write/action tools. Keeps the tool-schema budget small for
42
+ * low-context models while still exposing every read endpoint via the namespace.
43
+ */
44
+ export type OmadaRegistrationMode = 'eager' | 'graph';
45
+
46
+ export function registerOmadaTools(
47
+ server: McpServer,
48
+ client: OmadaClient,
49
+ mode: OmadaRegistrationMode = 'eager'
50
+ ): number {
51
+ if (mode === 'graph') {
52
+ return registerOmadaToolsGraph(server, client);
53
+ }
54
+
55
+ logger.debug('Registering Omada tools', { mode });
38
56
  let toolCount = 0;
39
57
 
40
58
  // Site tools
@@ -84,6 +102,33 @@ export function registerOmadaTools(server: McpServer, client: OmadaClient): numb
84
102
  registerGetFirewallSettingTool(server, client);
85
103
  toolCount += 8;
86
104
 
87
- logger.info('Omada tools registered', { toolCount });
105
+ logger.info('Omada tools registered', { mode, toolCount });
106
+ return toolCount;
107
+ }
108
+
109
+ /**
110
+ * Graph mode: resource-graph reads + typed writes/actions only.
111
+ *
112
+ * Reads collapse into `omada_browse` + `omada_read` (see ./graph.ts and
113
+ * ./namespace.ts). Writes stay as individual, explicitly permission-gated tools —
114
+ * they mutate real network state, so each keeps its own narrow schema and RBAC
115
+ * (CONTROL) rather than being funneled through a generic verb.
116
+ */
117
+ function registerOmadaToolsGraph(server: McpServer, client: OmadaClient): number {
118
+ logger.debug('Registering Omada tools', { mode: 'graph' });
119
+ let toolCount = 0;
120
+
121
+ // Resource-graph reads (browse + read)
122
+ toolCount += registerOmadaGraphTools(server, client);
123
+
124
+ // Typed write / action tools (unchanged, individually permission-gated)
125
+ registerSetClientRateLimitTool(server, client);
126
+ registerSetClientRateLimitProfileTool(server, client);
127
+ registerDisableClientRateLimitTool(server, client);
128
+ registerBlockClientTool(server, client);
129
+ registerUnblockClientTool(server, client);
130
+ toolCount += 5;
131
+
132
+ logger.info('Omada tools registered', { mode: 'graph', toolCount });
88
133
  return toolCount;
89
134
  }