@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.
- package/.env.example +9 -0
- package/CLAUDE.md +70 -8
- package/README.md +26 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/omadaClient/index.d.ts +29 -0
- package/dist/omadaClient/index.js +41 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/request.js +14 -1
- package/dist/omadaClient/request.js.map +1 -1
- package/dist/omadaClient/site.d.ts +16 -0
- package/dist/omadaClient/site.js +25 -0
- package/dist/omadaClient/site.js.map +1 -1
- package/dist/permissions/index.js +20 -5
- package/dist/permissions/index.js.map +1 -1
- package/dist/server/common.d.ts +3 -0
- package/dist/server/common.js +2 -1
- package/dist/server/common.js.map +1 -1
- package/dist/server/stdio.d.ts +3 -0
- package/dist/server/stdio.js.map +1 -1
- package/dist/server/stream.js +6 -1
- package/dist/server/stream.js.map +1 -1
- package/dist/tools/common.d.ts +12 -0
- package/dist/tools/common.js +15 -1
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/omada/graph.d.ts +22 -0
- package/dist/tools/omada/graph.js +120 -0
- package/dist/tools/omada/graph.js.map +1 -0
- package/dist/tools/omada/index.d.ts +9 -1
- package/dist/tools/omada/index.js +30 -3
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/namespace.d.ts +89 -0
- package/dist/tools/omada/namespace.js +496 -0
- package/dist/tools/omada/namespace.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CHANGELOG.md +63 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +7 -0
- package/src/index.ts +19 -2
- package/src/omadaClient/index.ts +65 -0
- package/src/omadaClient/request.ts +14 -1
- package/src/omadaClient/site.ts +29 -0
- package/src/permissions/index.ts +22 -5
- package/src/server/common.ts +5 -1
- package/src/server/stdio.ts +3 -0
- package/src/server/stream.ts +6 -1
- package/src/tools/common.ts +16 -2
- package/src/tools/index.ts +5 -3
- package/src/tools/omada/graph.ts +162 -0
- package/src/tools/omada/index.ts +48 -3
- package/src/tools/omada/namespace.ts +577 -0
- package/src/version.ts +1 -1
- package/tests/config.test.ts +30 -0
- package/tests/omadaClient/site.test.ts +37 -0
- package/tests/permissions/index.test.ts +21 -0
- package/tests/tools/omada/graph.test.ts +352 -0
- 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
|
-
|
|
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({
|
|
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);
|
package/src/omadaClient/index.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/omadaClient/site.ts
CHANGED
|
@@ -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
|
*/
|
package/src/permissions/index.ts
CHANGED
|
@@ -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
|
|
65
|
+
return fallback;
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
const user = config.users.find((u) => u.sub === sub);
|
|
50
69
|
if (!user) {
|
|
51
|
-
return
|
|
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
|
-
|
|
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
|
/**
|
package/src/server/common.ts
CHANGED
|
@@ -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)
|
package/src/server/stdio.ts
CHANGED
|
@@ -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> {
|
package/src/server/stream.ts
CHANGED
|
@@ -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({
|
|
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;
|
package/src/tools/common.ts
CHANGED
|
@@ -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)) {
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/omada/index.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
}
|