@enfyra/mcp-server 0.0.104 → 0.0.106

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
@@ -169,6 +169,7 @@ For normal apps and demos, enter the app/admin URL such as `http://localhost:300
169
169
  Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate implementation patterns. It returns focused examples for:
170
170
 
171
171
  - SSR app auth and proxy setup
172
+ - OAuth provider setup
172
173
  - schema, columns, relations, indexes, and validation
173
174
  - query filters, sorting, fields, deep relations, and aggregates
174
175
  - handlers, hooks, permissions, and RLS
@@ -190,7 +191,7 @@ The MCP server includes safety guards for LLM callers:
190
191
  - Relation tools reject physical FK/junction names.
191
192
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
192
193
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
193
- - Platform operation tools such as `api_endpoint_workflow`, `create_api_endpoint`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_script_flow_step`, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
194
+ - Platform operation tools such as `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_script_flow_step`, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
194
195
  - Schema changes are serialized.
195
196
  - Destructive deletes return a preview before requiring `confirm=true`.
196
197
 
@@ -228,7 +229,9 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
228
229
 
229
230
  ## Tool Summary
230
231
 
231
- The MCP server exposes tools for metadata discovery, examples, query/CRUD, method management, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, and `get_enfyra_api_context`.
232
+ The MCP server exposes tools for metadata discovery, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, and `get_enfyra_api_context`.
233
+
234
+ Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods`, `set_public_route_methods`, and `private_route_methods` for that access boundary.
232
235
 
233
236
  For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by route path plus role/user. For production script edits, use `trace_metadata_usage`, `get_script_source`, and `patch_script_source` so changes are targeted, hash-checked, and validated.
234
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -457,6 +457,84 @@ window.location.href = url.toString()`,
457
457
  },
458
458
  ],
459
459
  },
460
+ 'oauth-setup': {
461
+ title: 'OAuth provider setup',
462
+ useWhen: 'Use when configuring Google or another OAuth provider for an Enfyra-backed app.',
463
+ examples: [
464
+ {
465
+ name: 'Google OAuth setup workflow',
466
+ code: `// 1. Ask for the public app/admin URL, not the API URL.
467
+ // Example input from the user:
468
+ const appUrl = "https://demo.enfyra.io"
469
+
470
+ // 2. Derive the Enfyra API base and provider callback.
471
+ const apiBase = appUrl.replace(/\\/$/, "") + "/api"
472
+ const googleCallbackUrl = apiBase + "/auth/google/callback"
473
+
474
+ // 3. Tell the user to paste this exact value into Google Cloud Console:
475
+ // APIs & Services -> Credentials -> OAuth 2.0 Client -> Authorized redirect URIs
476
+ // https://demo.enfyra.io/api/auth/google/callback
477
+
478
+ // 4. After the user provides Google client id/secret, save Enfyra config:
479
+ create_record({
480
+ tableName: "enfyra_oauth_config",
481
+ body: JSON.stringify({
482
+ provider: "google",
483
+ clientId: "<google-client-id>",
484
+ clientSecret: "<google-client-secret>",
485
+ redirectUri: googleCallbackUrl,
486
+ isEnabled: true
487
+ })
488
+ })`,
489
+ notes: [
490
+ 'Ask for the app/admin URL such as https://demo.enfyra.io; derive the API base by appending /api.',
491
+ 'The provider callback is {appUrl}/api/auth/{provider}/callback and must exactly match the Authorized redirect URI in Google Cloud Console.',
492
+ 'Do not ask the user to choose or type the callback URL manually once the app URL is known; compute it and show the exact value to paste.',
493
+ 'The OAuth callback is the Enfyra provider callback, not the final app page.',
494
+ 'When starting OAuth from a browser app, use the same-origin proxy route with redirect and cookieBridgePrefix as shown in ssr-app-auth examples.',
495
+ ],
496
+ },
497
+ {
498
+ name: 'Browser OAuth start URL after setup',
499
+ code: `const returnUrl = new URL("/dashboard", window.location.origin)
500
+ const oauthUrl = new URL("/enfyra/auth/google", window.location.origin)
501
+ oauthUrl.searchParams.set("redirect", returnUrl.toString())
502
+ oauthUrl.searchParams.set("cookieBridgePrefix", "/enfyra")
503
+ window.location.href = oauthUrl.toString()`,
504
+ notes: [
505
+ 'This is the browser start URL through the app proxy; it is different from the Google Authorized redirect URI.',
506
+ 'After Enfyra finishes the Google callback, it bridges cookies through /enfyra/auth/set-cookies and returns to the absolute redirect URL.',
507
+ 'After return, call /enfyra/me to load the user.',
508
+ ],
509
+ },
510
+ {
511
+ name: 'Update an existing Google OAuth config',
512
+ code: `const existing = await query_table({
513
+ tableName: "enfyra_oauth_config",
514
+ filter: JSON.stringify({ provider: { _eq: "google" } }),
515
+ fields: ["id", "provider", "redirectUri", "isEnabled"],
516
+ limit: 1
517
+ })
518
+
519
+ // If a row exists, update it instead of creating a duplicate.
520
+ update_record({
521
+ tableName: "enfyra_oauth_config",
522
+ id: "<existing-config-id>",
523
+ body: JSON.stringify({
524
+ clientId: "<google-client-id>",
525
+ clientSecret: "<google-client-secret>",
526
+ redirectUri: "https://demo.enfyra.io/api/auth/google/callback",
527
+ isEnabled: true
528
+ })
529
+ })`,
530
+ notes: [
531
+ 'Inspect first so setup is idempotent.',
532
+ 'Use the current system table name enfyra_oauth_config.',
533
+ 'Never expose the client secret back in app code or documentation.',
534
+ ],
535
+ },
536
+ ],
537
+ },
460
538
  'schema-relations': {
461
539
  title: 'Tables, columns, relations, cascade, and indexes',
462
540
  useWhen: 'Use when creating or changing persisted data models.',
@@ -29,7 +29,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
29
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
30
  '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category.',
31
31
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
- '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
32
+ '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `enable_route`, `disable_route`, `delete_route`, `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
33
33
  '- Before saving standalone dynamic script or extension code, call `validate_dynamic_script` or `validate_extension_code` unless the chosen ensure/update tool already validates the code.',
34
34
  '- For existing script-backed records, use `trace_metadata_usage` then `get_script_source`; edit with `patch_script_source` or `update_script_source` so source is hash-checked and validated.',
35
35
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
@@ -51,7 +51,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
51
51
  '',
52
52
  '### App Connection Defaults',
53
53
  '- Generated Nuxt/Next/SSR apps should use a same-origin proxy such as `/enfyra/**` to the Enfyra API. Browser code calls `/enfyra/login`, `/enfyra/me`, `/enfyra/logout`, and `/enfyra/<table>`; it should not store JWTs.',
54
- '- OAuth starts through the same proxy prefix with `redirect=<absoluteReturnUrl>` and `cookieBridgePrefix=/enfyra`. OAuth setup details live in `get_enfyra_examples({ category: "ssr-app-auth" })`.',
54
+ '- OAuth starts through the same proxy prefix with `redirect=<absoluteReturnUrl>` and `cookieBridgePrefix=/enfyra`. Provider setup details live in `get_enfyra_examples({ category: "oauth-setup" })`.',
55
55
  '- Socket.IO browser clients connect to the gateway namespace, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, while the app proxies `/socket.io/**` to Enfyra `/ws/socket.io/**`.',
56
56
  '',
57
57
  '### Dynamic Script Surface',
@@ -60,7 +60,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
60
60
  '',
61
61
  '### Direct HTTP Mapping',
62
62
  '- Route-backed table CRUD is REST: `GET /<table>?...`, `POST /<table>`, `PATCH /<table>/<id>`, `DELETE /<table>/<id>`. There is no `GET /<table>/<id>`; use a filtered list with `limit=1` or `find_one_record`.',
63
- '- REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL requires Bearer auth and table GraphQL enablement.',
63
+ '- REST route lifecycle is controlled by `enfyra_route.isEnabled`: disabled routes are not registered at runtime and return 404. Use `enable_route`/`disable_route` instead of raw route PATCH. REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL requires Bearer auth and table GraphQL enablement.',
64
64
  '',
65
65
  'When the user asks for details, fetch only the relevant live context or example category instead of relying on broad memorized rules.',
66
66
  ].join('\n');
@@ -164,6 +164,115 @@ async function updateRoutePublicMethods(apiUrl, { path, routeId, methods, mode }
164
164
  };
165
165
  }
166
166
 
167
+ async function setRouteEnabled(apiUrl, { path, routeId, isEnabled }) {
168
+ const { route } = await resolveRoute(apiUrl, { path, routeId });
169
+ const before = route?.isEnabled !== false;
170
+ if (before === isEnabled) {
171
+ return {
172
+ action: isEnabled ? 'route_already_enabled' : 'route_already_disabled',
173
+ route: { id: getId(route), path: route.path },
174
+ before: { isEnabled: before },
175
+ after: { isEnabled },
176
+ runtimeBehavior: isEnabled ? 'Enabled routes are registered at runtime.' : 'Disabled routes are not registered at runtime and return 404.',
177
+ routeReload: { attempted: false, succeeded: true, reason: 'No route lifecycle change was needed.' },
178
+ };
179
+ }
180
+
181
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
182
+ method: 'PATCH',
183
+ body: JSON.stringify({ isEnabled }),
184
+ });
185
+ const routeReload = await reloadRoutes(apiUrl);
186
+ return {
187
+ action: isEnabled ? 'route_enabled' : 'route_disabled',
188
+ route: { id: getId(route), path: route.path },
189
+ before: { isEnabled: before },
190
+ after: { isEnabled },
191
+ runtimeBehavior: isEnabled ? 'The route should now be registered at runtime.' : 'The route should now return 404 because disabled routes are not registered at runtime.',
192
+ result,
193
+ routeReload,
194
+ };
195
+ }
196
+
197
+ async function fetchRouteDependencies(apiUrl, routeId) {
198
+ const routeFilter = filterQuery({ route: { id: { _eq: routeId } } });
199
+ const routeIdFilter = filterQuery({ routeId: { _eq: routeId } });
200
+ const [handlers, permissions, preHooks, postHooks, guards] = await Promise.all([
201
+ fetchAll(apiUrl, `/enfyra_route_handler?filter=${routeIdFilter}&fields=id,_id,routeId,method.name&limit=0`),
202
+ fetchAll(apiUrl, `/enfyra_route_permission?filter=${routeFilter}&fields=id,_id,route.id,role.name,isEnabled&limit=0`),
203
+ fetchAll(apiUrl, `/enfyra_pre_hook?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
204
+ fetchAll(apiUrl, `/enfyra_post_hook?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
205
+ fetchAll(apiUrl, `/enfyra_guard?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
206
+ ]);
207
+ return { handlers, permissions, preHooks, postHooks, guards };
208
+ }
209
+
210
+ function summarizeRouteDependencies(dependencies) {
211
+ return {
212
+ handlers: dependencies.handlers.map((item) => ({ id: getId(item), method: item?.method?.name || null })),
213
+ permissions: dependencies.permissions.map((item) => ({ id: getId(item), role: item?.role?.name || null, isEnabled: item?.isEnabled !== false })),
214
+ preHooks: dependencies.preHooks.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
215
+ postHooks: dependencies.postHooks.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
216
+ guards: dependencies.guards.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
217
+ };
218
+ }
219
+
220
+ async function deleteRows(apiUrl, tableName, rows) {
221
+ const deleted = [];
222
+ for (const row of rows) {
223
+ const id = getId(row);
224
+ if (id === null || id === undefined) continue;
225
+ await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(id))}`, { method: 'DELETE' });
226
+ deleted.push(id);
227
+ }
228
+ return deleted;
229
+ }
230
+
231
+ async function deleteRoute(apiUrl, { path, routeId, expectedPath, confirm }) {
232
+ const { route } = await resolveRoute(apiUrl, { path, routeId });
233
+ if (expectedPath && route.path !== normalizeRestPath(expectedPath)) {
234
+ throw new Error(`Route path mismatch: resolved ${route.path}, expected ${normalizeRestPath(expectedPath)}.`);
235
+ }
236
+
237
+ const dependencies = await fetchRouteDependencies(apiUrl, getId(route));
238
+ const dependencySummary = summarizeRouteDependencies(dependencies);
239
+ const preview = {
240
+ route: { id: getId(route), path: route.path, isEnabled: route?.isEnabled !== false },
241
+ dependencies: dependencySummary,
242
+ };
243
+
244
+ if (!confirm) {
245
+ return {
246
+ action: 'delete_route_preview',
247
+ ...preview,
248
+ next: 'Call delete_route again with confirm=true and expectedPath set to this route path to delete the route and related handlers/hooks/permissions/guards.',
249
+ };
250
+ }
251
+
252
+ await deleteRows(apiUrl, 'enfyra_route_handler', dependencies.handlers);
253
+ await deleteRows(apiUrl, 'enfyra_pre_hook', dependencies.preHooks);
254
+ await deleteRows(apiUrl, 'enfyra_post_hook', dependencies.postHooks);
255
+ await deleteRows(apiUrl, 'enfyra_guard', dependencies.guards);
256
+ await deleteRows(apiUrl, 'enfyra_route_permission', dependencies.permissions);
257
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, { method: 'DELETE' });
258
+ const routeReload = await reloadRoutes(apiUrl);
259
+
260
+ return {
261
+ action: 'route_deleted',
262
+ ...preview,
263
+ deleted: {
264
+ handlers: dependencies.handlers.map(getId).filter((id) => id !== null && id !== undefined),
265
+ permissions: dependencies.permissions.map(getId).filter((id) => id !== null && id !== undefined),
266
+ preHooks: dependencies.preHooks.map(getId).filter((id) => id !== null && id !== undefined),
267
+ postHooks: dependencies.postHooks.map(getId).filter((id) => id !== null && id !== undefined),
268
+ guards: dependencies.guards.map(getId).filter((id) => id !== null && id !== undefined),
269
+ route: getId(route),
270
+ },
271
+ result,
272
+ routeReload,
273
+ };
274
+ }
275
+
167
276
  async function findHandler(apiUrl, routeId, methodId) {
168
277
  const filter = encodeURIComponent(JSON.stringify({
169
278
  route: { id: { _eq: routeId } },
@@ -882,6 +991,46 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
882
991
  })),
883
992
  );
884
993
 
994
+ server.tool(
995
+ 'enable_route',
996
+ 'Business operation: enable an existing route. Enabled routes are registered at runtime; disabled routes return 404.',
997
+ {
998
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
999
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1000
+ },
1001
+ async ({ path, routeId }) => jsonText(await setRouteEnabled(ENFYRA_API_URL, {
1002
+ path,
1003
+ routeId,
1004
+ isEnabled: true,
1005
+ })),
1006
+ );
1007
+
1008
+ server.tool(
1009
+ 'disable_route',
1010
+ 'Business operation: disable an existing route without deleting metadata. Disabled routes are not registered at runtime and return 404.',
1011
+ {
1012
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1013
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1014
+ },
1015
+ async ({ path, routeId }) => jsonText(await setRouteEnabled(ENFYRA_API_URL, {
1016
+ path,
1017
+ routeId,
1018
+ isEnabled: false,
1019
+ })),
1020
+ );
1021
+
1022
+ server.tool(
1023
+ 'delete_route',
1024
+ 'Business operation: preview-first delete for a route and its route-owned handlers, hooks, guards, and permissions. Use only when a route contract is retired.',
1025
+ {
1026
+ path: z.string().optional().describe('Route path, e.g. /old-endpoint. Use either path or routeId.'),
1027
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1028
+ expectedPath: z.string().optional().describe('Optional safety check. When confirm=true, pass the path returned by the preview.'),
1029
+ confirm: z.boolean().optional().default(false).describe('false returns a dependency preview only; true deletes the route and related route-owned records.'),
1030
+ },
1031
+ async (input) => jsonText(await deleteRoute(ENFYRA_API_URL, input)),
1032
+ );
1033
+
885
1034
  server.tool(
886
1035
  'public_route_methods',
887
1036
  'Business operation: make existing route methods public/anonymous.',