@enfyra/mcp-server 0.0.71 → 0.0.73

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/README.md CHANGED
@@ -186,6 +186,8 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
186
186
 
187
187
  Schema and script tools include safety guards for LLM callers: generic record mutations validate request fields against live metadata, script-backed records must validate `sourceCode` before save through `/admin/script/validate` and fail closed if validation is unavailable, relation metadata rejects physical FK/junction inputs, custom routes reject `mainTableId` unless the path is the canonical table route, schema tools serialize table/column/relation changes, and destructive deletes require `confirm=true` after returning a preview.
188
188
 
189
+ Read tools use Enfyra's `fields` parameter directly. Passing explicit includes such as `fields=id,email` returns only those fields, while any `-field` token switches that scope to exclude mode. For example, `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Nested exclusions work with dotted fields and `deep`, such as `fields=-owner.avatar` or `deep.owner.fields=-avatar`.
190
+
189
191
  Quick checklist for a new LLM using Enfyra MCP: discover the live system first, inspect the specific table/route, load the matching example category, mutate with explicit fields and relation property names, validate or test scripts/routes before relying on them, re-read the saved row when mutation output is summarized, and preview destructive operations before confirming.
190
192
 
191
193
  Use `update_script_source` when updating existing long script-backed records such as `flow_step_definition`, `route_handler_definition`, hook tables, websocket scripts, GraphQL scripts, or bootstrap scripts. It accepts raw `sourceCode` directly, validates the source, and saves `sourceCode`/`scriptLanguage` without requiring the caller to manually JSON-escape the full script. Use generic `update_record` for small record patches or patches that include non-script metadata fields.
@@ -219,10 +221,12 @@ When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the sa
219
221
 
220
222
  ## Tools (summary)
221
223
 
222
- Metadata, examples, query/CRUD, method management, route/handler/hook, tables/columns, reload cache, logs, user/roles, login, menu/extension, `get_enfyra_api_context`. For full tool list and behavior, see the app after enabling MCP or the source in `src/mcp-server-entry.mjs`.
224
+ Metadata, examples, query/CRUD, method management, route access audit/grant, route/handler/hook, tables/columns, reload cache, logs, user/roles, login, menu/extension, `get_enfyra_api_context`. For full tool list and behavior, see the app after enabling MCP or the source in `src/mcp-server-entry.mjs`.
223
225
 
224
226
  Use `get_enfyra_examples` when asking an LLM to generate concrete Enfyra implementation patterns. It returns categorized examples for SSR app auth/OAuth/proxy setup, schema/relations, queries/deep, handlers/hooks, permissions/RLS, websocket, flows, files, and extensions.
225
227
 
228
+ For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by `path` plus `roleName`/`roleId` or `allowedUserIds`. `ensure_route_access` resolves route, role, and method ids, validates that requested methods are available on the route, merges existing methods by default, and reloads routes.
229
+
226
230
  ## Security
227
231
 
228
232
  API calls use JWT (MCP auto-refreshes). Permissions are enforced by Enfyra.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -419,6 +419,34 @@ GET /enfyra/post?filter={"<primaryKeyFromMetadata>":{"_eq":123}}&limit=1`,
419
419
  'Do not add deep when fields alone can express the relation data you need.',
420
420
  ],
421
421
  },
422
+ {
423
+ name: 'Exclude large generated fields',
424
+ code: `query_table({
425
+ tableName: "route_handler_definition",
426
+ fields: ["-compiledCode"],
427
+ limit: 20
428
+ })
429
+
430
+ query_table({
431
+ tableName: "post",
432
+ fields: ["id", "-author.avatar"],
433
+ deep: JSON.stringify({
434
+ comments: {
435
+ fields: "-compiledCode,-author.avatar",
436
+ limit: 10,
437
+ deep: {
438
+ author: { fields: "-avatar" }
439
+ }
440
+ }
441
+ })
442
+ })`,
443
+ notes: [
444
+ 'Use fields=-compiledCode when reading script-backed records; sourceCode is the editable contract and compiledCode is generated by the server.',
445
+ 'Any -field token switches that fields scope to exclude mode, so fields=id,-compiledCode returns all readable fields except compiledCode.',
446
+ 'Dotted exclusions and deep relation fields use the same exclude-mode rule.',
447
+ 'Excluded fields and relations must exist in metadata; typos should fail instead of silently returning large or sensitive fields.',
448
+ ],
449
+ },
422
450
  {
423
451
  name: 'Deep relation query options',
424
452
  code: `query_table({
@@ -651,6 +679,26 @@ return @DATA\`
651
679
  title: 'Route permissions, guards, field permissions, column rules, and RLS',
652
680
  useWhen: 'Use when securing routes or shaping what fields a user can read/write.',
653
681
  examples: [
682
+ {
683
+ name: 'Audit and grant authenticated route access',
684
+ code: `audit_route_access({
685
+ path: "/orders",
686
+ roleName: "user",
687
+ methods: ["GET", "POST"]
688
+ })
689
+
690
+ ensure_route_access({
691
+ path: "/orders",
692
+ roleName: "user",
693
+ methods: ["GET", "POST"],
694
+ description: "Authenticated users can list and create their own orders."
695
+ })`,
696
+ notes: [
697
+ 'Use route permissions for authenticated access. The tool resolves role and method ids, validates the route available methods, merges existing methods, and reloads routes.',
698
+ 'Handlers or pre-hooks must still enforce owner or tenant scope; route permission only lets the request pass RoleGuard.',
699
+ 'Use publishedMethods only for anonymous public access.',
700
+ ],
701
+ },
654
702
  {
655
703
  name: 'Publish read-only route',
656
704
  code: `update_record({
@@ -72,7 +72,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
72
72
  '',
73
73
  '### Routes vs tables (custom endpoints, handlers, hooks)',
74
74
  '- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
75
- '- Use **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, **`create_route_permission`** for authenticated route access, and **`create_guard`** for pre/post-auth request gates.',
75
+ '- Use **`audit_route_access`** before debugging route 403s or granting access. Use **`ensure_route_access`** for authenticated role/user route access because it resolves route, role, method ids, validates route.availableMethods, merges existing permissions, and reloads routes. Use low-level **`create_route_permission`** only when you intentionally need a new raw permission row.',
76
+ '- Use **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, and **`create_guard`** for pre/post-auth request gates.',
76
77
  '- Prefer these REST inspection/operator tools over raw `query_table` on system tables when changing route behavior. They resolve ids, methods, route paths, code previews, and cache reloads for the model.',
77
78
  '- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** and **omit `mainTableId`**. `mainTable` is only a marker for canonical table routes like `/orders`; custom paths such as `/orders/stats`, `/reports/summary`, `/auth/login`, or `/me` must not set it.',
78
79
  '- **Wrong pattern:** calling **`create_table`** just to get an HTTP path, then overriding handlers on the **default** auto route `/{table_name}`. That adds unnecessary schema and breaks the usual CRUD surface for that table.',
@@ -244,6 +245,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
244
245
  '- Field permission condition DSL is narrower and does not support `_contains`, `_starts_with`, `_ends_with`, or `_between`.',
245
246
  '- Root `sort` accepts local fields such as `-createdAt` plus direct list-relation aggregate helpers: `_count(relationName)`, `_max(relationName.fieldName)`, and `_min(relationName.fieldName)`. Use `-_max(messages.createdAt)` to order parent rows by the latest child row. The relation must be direct `one-to-many` or `many-to-many`, and the aggregate field must be a non-encrypted scalar field on the related table.',
246
247
  '- Raw dotted to-many sort such as `messages.createdAt` is invalid for parent ordering. `deep: { messages: { sort: "-createdAt" } }` sorts the loaded child rows inside each parent only; it does not sort the parent list.',
248
+ '- Field selection has two modes per query scope. Include mode is the default: `fields=id,email,owner.name` returns only those fields. If any token starts with `-`, that scope switches to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode` because positive tokens are ignored in exclude mode. Dotted exclusions such as `fields=-owner.avatar` and nested deep fields such as `deep: { owner: { fields: "-avatar" } }` also switch that scope to exclude mode. Unknown excluded fields/relations are request errors, so inspect metadata before excluding guessed names.',
247
249
  '- Deep shape: `{ relationName: { fields?, filter?, sort?, limit?, page?, deep? } }`. Relation keys are relation `propertyName`, not physical FK columns.',
248
250
  '- Use dotted relation fields such as `owner.email` or `lastMessage.text` when the caller only needs basic related record fields. Use `deep` when relation loading needs query options such as `filter`, `sort`, `limit`, `page`, or nested `deep`. Do not use `deep` for simple relation-id filters, one-row lookup, counts, or large child collections that should be loaded separately with pagination.',
249
251
  '- Deep validation rejects unknown relation keys, unknown subkeys, `limit` on many-to-one/one-to-one, and invalid dotted sort through many-side relations.',
@@ -0,0 +1,160 @@
1
+ export function normalizeMethodName(method) {
2
+ return String(method || '').trim().toUpperCase();
3
+ }
4
+
5
+ export function normalizeMethodNames(methods) {
6
+ return [...new Set((methods || []).map(normalizeMethodName).filter(Boolean))];
7
+ }
8
+
9
+ export function getRecordId(record) {
10
+ return record?.id ?? record?._id ?? null;
11
+ }
12
+
13
+ export function getReferenceId(value) {
14
+ return typeof value === 'object' && value !== null ? getRecordId(value) : value;
15
+ }
16
+
17
+ export function sameRecordId(a, b) {
18
+ if (a === null || a === undefined || b === null || b === undefined) return false;
19
+ return String(a) === String(b);
20
+ }
21
+
22
+ export function resolveRoleByNameOrId(roles, { roleId, roleName } = {}) {
23
+ if (roleId && roleName) {
24
+ throw new Error('Provide roleId or roleName, not both.');
25
+ }
26
+ if (!roleId && !roleName) return null;
27
+ const normalizedRoleName = roleName ? String(roleName).trim().toLowerCase() : null;
28
+ const role = roles.find((item) => (
29
+ roleId
30
+ ? sameRecordId(getRecordId(item), roleId)
31
+ : String(item?.name || '').trim().toLowerCase() === normalizedRoleName
32
+ ));
33
+ if (!role) throw new Error(`Role not found: ${roleId || roleName}`);
34
+ return role;
35
+ }
36
+
37
+ export function methodNamesFromRecords(methods, methodIdNameMap = {}) {
38
+ return normalizeMethodNames((methods || []).map((method) => (
39
+ method?.name || method?.method || methodIdNameMap[String(getRecordId(method))] || method
40
+ )));
41
+ }
42
+
43
+ export function routeAvailableMethodNames(route, methodIdNameMap = {}) {
44
+ return methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
45
+ }
46
+
47
+ export function routePublishedMethodNames(route, methodIdNameMap = {}) {
48
+ return methodNamesFromRecords(route?.publishedMethods || [], methodIdNameMap);
49
+ }
50
+
51
+ export function permissionMethodNames(permission, methodIdNameMap = {}) {
52
+ return methodNamesFromRecords(permission?.methods || [], methodIdNameMap);
53
+ }
54
+
55
+ export function validateMethodsForRoute(route, methods, methodMap, methodIdNameMap = {}) {
56
+ const normalizedMethods = normalizeMethodNames(methods);
57
+ const knownMethods = new Set(Object.keys(methodMap || {}).map(normalizeMethodName));
58
+ const unknown = normalizedMethods.filter((method) => !knownMethods.has(method));
59
+ if (unknown.length) {
60
+ throw new Error(`Unknown method_definition.name values: ${unknown.join(', ')}`);
61
+ }
62
+
63
+ const availableMethods = routeAvailableMethodNames(route, methodIdNameMap);
64
+ if (availableMethods.length) {
65
+ const unavailable = normalizedMethods.filter((method) => !availableMethods.includes(method));
66
+ if (unavailable.length) {
67
+ throw new Error(`Route ${route?.path} does not list methods as available: ${unavailable.join(', ')}. Available: ${availableMethods.join(', ')}`);
68
+ }
69
+ }
70
+
71
+ return normalizedMethods;
72
+ }
73
+
74
+ export function sortedIdStrings(values) {
75
+ return [...new Set((values || []).map((value) => String(getReferenceId(value))).filter(Boolean))].sort();
76
+ }
77
+
78
+ export function sameIdSet(a, b) {
79
+ const left = sortedIdStrings(a);
80
+ const right = sortedIdStrings(b);
81
+ return left.length === right.length && left.every((value, index) => value === right[index]);
82
+ }
83
+
84
+ export function routePermissionMatchesScope(permission, { roleId, allowedUserIds } = {}) {
85
+ const expectedUsers = sortedIdStrings(allowedUserIds);
86
+ const permissionUsers = sortedIdStrings(permission?.allowedUsers || []);
87
+ const permissionRoleId = getReferenceId(permission?.role);
88
+
89
+ if (roleId && !sameRecordId(permissionRoleId, roleId)) return false;
90
+ if (!roleId && permissionRoleId !== null && permissionRoleId !== undefined) return false;
91
+ return sameIdSet(permissionUsers, expectedUsers);
92
+ }
93
+
94
+ export function findRoutePermission(routePermissions, routeId, scope) {
95
+ return (routePermissions || []).find((permission) => (
96
+ sameRecordId(getReferenceId(permission?.route), routeId)
97
+ && routePermissionMatchesScope(permission, scope)
98
+ )) || null;
99
+ }
100
+
101
+ export function mergeMethodNames(existingMethods, requestedMethods, mode = 'merge') {
102
+ const requested = normalizeMethodNames(requestedMethods);
103
+ if (mode === 'replace') return requested;
104
+ return normalizeMethodNames([...normalizeMethodNames(existingMethods), ...requested]);
105
+ }
106
+
107
+ export function summarizeRoutePermission(permission, methodIdNameMap = {}) {
108
+ return {
109
+ id: getRecordId(permission),
110
+ isEnabled: permission?.isEnabled !== false,
111
+ description: permission?.description || null,
112
+ route: permission?.route?.path || permission?.route || null,
113
+ role: permission?.role ? {
114
+ id: getReferenceId(permission.role),
115
+ name: permission.role.name || null,
116
+ } : null,
117
+ allowedUsers: (permission?.allowedUsers || []).map((user) => ({
118
+ id: getReferenceId(user),
119
+ email: user?.email || null,
120
+ })),
121
+ methods: permissionMethodNames(permission, methodIdNameMap),
122
+ };
123
+ }
124
+
125
+ export function summarizeRouteAccess(route, routePermissions, methodIdNameMap = {}, expected = {}) {
126
+ const routeId = getRecordId(route);
127
+ const permissions = (routePermissions || [])
128
+ .filter((permission) => sameRecordId(getReferenceId(permission?.route), routeId))
129
+ .map((permission) => summarizeRoutePermission(permission, methodIdNameMap));
130
+
131
+ const expectedMethods = normalizeMethodNames(expected.methods || []);
132
+ const expectedRoleId = expected.roleId ? String(expected.roleId) : null;
133
+ const expectedAllowedUsers = sortedIdStrings(expected.allowedUserIds || []);
134
+ const hasExpectedScope = !!(expectedRoleId || expected.roleRequired || expected.allowedUserIds !== undefined);
135
+ const matchingPermissions = permissions.filter((permission) => {
136
+ if (!hasExpectedScope) return true;
137
+ if (expectedRoleId && String(permission.role?.id) !== expectedRoleId) return false;
138
+ if (!expectedRoleId && expected.roleRequired && permission.role) return false;
139
+ if (!sameIdSet(permission.allowedUsers.map((user) => user.id), expectedAllowedUsers)) return false;
140
+ return true;
141
+ });
142
+ const grantedMethods = normalizeMethodNames(matchingPermissions.flatMap((permission) => (
143
+ permission.isEnabled ? permission.methods : []
144
+ )));
145
+
146
+ return {
147
+ id: routeId,
148
+ path: route?.path,
149
+ isEnabled: route?.isEnabled !== false,
150
+ availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
151
+ publishedMethods: routePublishedMethodNames(route, methodIdNameMap),
152
+ skipRoleGuardMethods: methodNamesFromRecords(route?.skipRoleGuardMethods || [], methodIdNameMap),
153
+ permissions,
154
+ expected: expectedMethods.length ? {
155
+ methods: expectedMethods,
156
+ grantedMethods,
157
+ missingMethods: expectedMethods.filter((method) => !grantedMethods.includes(method)),
158
+ } : null,
159
+ };
160
+ }
@@ -21,6 +21,17 @@ import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
21
21
  import { registerTableTools } from './lib/table-tools.js';
22
22
  import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
23
23
  import { validateMainTableRoutePath } from './lib/route-guards.js';
24
+ import {
25
+ findRoutePermission,
26
+ mergeMethodNames,
27
+ normalizeMethodNames,
28
+ resolveRoleByNameOrId,
29
+ routeAvailableMethodNames,
30
+ routePublishedMethodNames,
31
+ summarizeRouteAccess,
32
+ summarizeRoutePermission,
33
+ validateMethodsForRoute,
34
+ } from './lib/route-permission-tools.js';
24
35
 
25
36
  // Initialize auth module
26
37
  initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
@@ -2092,6 +2103,179 @@ server.tool(
2092
2103
  },
2093
2104
  );
2094
2105
 
2106
+ server.tool(
2107
+ 'audit_route_access',
2108
+ [
2109
+ 'Audit route access for one or more routes.',
2110
+ 'Use this before granting access or debugging 403s. It reports available methods, public published methods, skipRoleGuard methods, route permissions, and optional missing methods for one role/user scope.',
2111
+ ].join(' '),
2112
+ {
2113
+ path: z.string().optional().describe('Exact route path, e.g. /orders'),
2114
+ routeId: z.union([z.string(), z.number()]).optional().describe('Exact route id'),
2115
+ search: z.string().optional().describe('Optional route path search when path/routeId is not provided'),
2116
+ roleId: z.union([z.string(), z.number()]).optional().describe('Expected role id to check'),
2117
+ roleName: z.string().optional().describe('Expected role name to resolve, e.g. user'),
2118
+ allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Expected direct/specific user ids to check'),
2119
+ methods: z.array(z.string()).optional().describe('Methods expected to be allowed for this scope'),
2120
+ limit: z.number().int().positive().max(100).optional().default(25).describe('Maximum routes returned for search mode'),
2121
+ },
2122
+ async ({ path, routeId, search, roleId, roleName, allowedUserIds, methods, limit }) => {
2123
+ if ([path, routeId, search].filter((value) => value !== undefined && value !== null && value !== '').length > 1) {
2124
+ throw new Error('Use only one of path, routeId, or search.');
2125
+ }
2126
+ if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
2127
+
2128
+ const [routes, routePermissions, roles, methodIdNameMap] = await Promise.all([
2129
+ fetchAll('/route_definition?limit=1000'),
2130
+ fetchAll('/route_permission_definition?limit=1000'),
2131
+ fetchAll('/role_definition?limit=1000'),
2132
+ getMethodIdNameMap(),
2133
+ ]);
2134
+
2135
+ const role = resolveRoleByNameOrId(roles, { roleId, roleName });
2136
+ const normalizedPath = path ? normalizeRestPath(path) : null;
2137
+ const query = search ? String(search).toLowerCase() : null;
2138
+ const matchedRoutes = routes.filter((route) => {
2139
+ if (routeId) return sameId(getId(route), routeId);
2140
+ if (normalizedPath) return route.path === normalizedPath;
2141
+ if (query) return String(route.path || '').toLowerCase().includes(query);
2142
+ return true;
2143
+ }).slice(0, limit);
2144
+
2145
+ const expectedMethods = normalizeMethodNames(methods || []);
2146
+ const payload = {
2147
+ guidance: {
2148
+ publicAccess: 'publishedMethods bypass RoleGuard and do not require route_permission_definition.',
2149
+ authenticatedAccess: 'For non-public methods, eApp PermissionGate and backend RoleGuard both expect enabled route_permission_definition rows with matching route + HTTP method.',
2150
+ directUserAccess: 'allowedRoutePermissions on /me represent direct user-scoped route permissions; role.routePermissions represent role-scoped permissions.',
2151
+ },
2152
+ expectedScope: {
2153
+ role: role ? { id: getId(role), name: role.name } : null,
2154
+ allowedUserIds: allowedUserIds || [],
2155
+ methods: expectedMethods,
2156
+ },
2157
+ returnedRouteCount: matchedRoutes.length,
2158
+ routes: matchedRoutes.map((route) => summarizeRouteAccess(route, routePermissions, methodIdNameMap, {
2159
+ roleId: role ? getId(role) : roleId,
2160
+ roleRequired: !!(role || roleId || roleName),
2161
+ allowedUserIds,
2162
+ methods: expectedMethods,
2163
+ })),
2164
+ };
2165
+
2166
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2167
+ },
2168
+ );
2169
+
2170
+ server.tool(
2171
+ 'ensure_route_access',
2172
+ [
2173
+ 'Create or update authenticated route access for one role/user scope.',
2174
+ 'Use this instead of raw route_permission_definition CRUD when fixing 403s. It resolves roleName/route/method ids, validates route.availableMethods, merges existing permission methods by default, and reloads routes.',
2175
+ ].join(' '),
2176
+ {
2177
+ path: z.string().optional().describe('Route path, e.g. /orders'),
2178
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
2179
+ methods: z.array(z.string()).describe('HTTP method names to allow, e.g. ["GET", "POST"].'),
2180
+ roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2181
+ roleName: z.string().optional().describe('Role name scope, e.g. user. Prefer this when an LLM does not know role ids.'),
2182
+ allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope. Omit for role-wide access.'),
2183
+ mode: z.enum(['merge', 'replace']).optional().default('merge').describe('merge adds methods to an existing permission; replace overwrites methods on the matched permission.'),
2184
+ description: z.string().optional().describe('Admin note'),
2185
+ isEnabled: z.boolean().optional().default(true).describe('Enable the permission'),
2186
+ },
2187
+ async ({ path, routeId, methods, roleId, roleName, allowedUserIds, mode, description, isEnabled }) => {
2188
+ if (!path && !routeId) throw new Error('Provide path or routeId.');
2189
+ if (path && routeId) throw new Error('Provide path or routeId, not both.');
2190
+ if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
2191
+ if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
2192
+ throw new Error('Provide roleId, roleName, or allowedUserIds.');
2193
+ }
2194
+
2195
+ const [routes, routePermissions, roles, methodMap, methodIdNameMap] = await Promise.all([
2196
+ fetchAll('/route_definition?limit=1000'),
2197
+ fetchAll('/route_permission_definition?limit=1000'),
2198
+ fetchAll('/role_definition?limit=1000'),
2199
+ getMethodMap(),
2200
+ getMethodIdNameMap(),
2201
+ ]);
2202
+ const route = routes.find((item) => (
2203
+ routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2204
+ ));
2205
+ if (!route) throw new Error(`Route not found: ${routeId || path}`);
2206
+
2207
+ const role = resolveRoleByNameOrId(roles, { roleId, roleName });
2208
+ const scope = {
2209
+ roleId: role ? getId(role) : roleId,
2210
+ allowedUserIds: allowedUserIds || [],
2211
+ };
2212
+ const requestedMethods = validateMethodsForRoute(route, methods, methodMap, methodIdNameMap);
2213
+ const existing = findRoutePermission(routePermissions, getId(route), scope);
2214
+ const existingMethods = existing ? summarizeRoutePermission(existing, methodIdNameMap).methods : [];
2215
+ const finalMethods = mergeMethodNames(existingMethods, requestedMethods, mode);
2216
+ const methodRefs = resolveMethodIds(methodMap, finalMethods);
2217
+ const publishedMethods = routePublishedMethodNames(route, methodIdNameMap);
2218
+ const alreadyPublic = requestedMethods.filter((method) => publishedMethods.includes(method));
2219
+
2220
+ let result;
2221
+ let action;
2222
+ if (existing) {
2223
+ action = 'updated';
2224
+ const patchBody = {
2225
+ isEnabled,
2226
+ methods: methodRefs,
2227
+ ...(description !== undefined ? { description } : {}),
2228
+ };
2229
+ result = await fetchAPI(ENFYRA_API_URL, `/route_permission_definition/${encodeURIComponent(String(getId(existing)))}`, {
2230
+ method: 'PATCH',
2231
+ body: JSON.stringify(patchBody),
2232
+ });
2233
+ } else {
2234
+ action = 'created';
2235
+ const createBody = {
2236
+ isEnabled,
2237
+ description,
2238
+ route: { id: getId(route) },
2239
+ methods: methodRefs,
2240
+ ...(scope.roleId ? { role: { id: scope.roleId } } : {}),
2241
+ ...(scope.allowedUserIds.length ? { allowedUsers: scope.allowedUserIds.map((id) => ({ id })) } : {}),
2242
+ };
2243
+ result = await fetchAPI(ENFYRA_API_URL, '/route_permission_definition', {
2244
+ method: 'POST',
2245
+ body: JSON.stringify(createBody),
2246
+ });
2247
+ }
2248
+
2249
+ const routeReload = await reloadRoutesResult();
2250
+ const saved = firstDataRecord(result);
2251
+ const payload = {
2252
+ action,
2253
+ kind: 'route_access',
2254
+ route: {
2255
+ id: getId(route),
2256
+ path: route.path,
2257
+ availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
2258
+ publishedMethods,
2259
+ },
2260
+ scope: {
2261
+ role: role ? { id: getId(role), name: role.name } : null,
2262
+ allowedUserIds: scope.allowedUserIds,
2263
+ },
2264
+ permission: {
2265
+ id: getId(saved) || getId(existing),
2266
+ methods: finalMethods,
2267
+ alreadyPublic,
2268
+ isEnabled,
2269
+ },
2270
+ result,
2271
+ routeReload,
2272
+ auditHint: `Call audit_route_access({ path: "${route.path}", ${role ? `roleName: "${role.name}", ` : ''}methods: ${JSON.stringify(requestedMethods)} }) to verify.`,
2273
+ };
2274
+
2275
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2276
+ },
2277
+ );
2278
+
2095
2279
  server.tool(
2096
2280
  'create_guard',
2097
2281
  [