@enfyra/mcp-server 0.0.100 → 0.0.101

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
@@ -189,7 +189,7 @@ The MCP server includes safety guards for LLM callers:
189
189
  - Relation tools reject physical FK/junction names.
190
190
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
191
191
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
192
- - Platform operation tools such as `create_api_endpoint`, `publish_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.
192
+ - 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.
193
193
  - Schema changes are serialized.
194
194
  - Destructive deletes return a preview before requiring `confirm=true`.
195
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.100",
3
+ "version": "0.0.101",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -995,13 +995,13 @@ ensure_route_access({
995
995
  ],
996
996
  },
997
997
  {
998
- name: 'Publish read-only route',
999
- code: `publish_route_methods({
998
+ name: 'Make a read-only route public',
999
+ code: `public_route_methods({
1000
1000
  path: "/articles",
1001
1001
  methods: ["GET"]
1002
1002
  })`,
1003
1003
  notes: [
1004
- 'Use publish_route_methods instead of raw enfyra_route updates; the tool resolves method ids and validates that GET is available.',
1004
+ 'Use public_route_methods instead of raw enfyra_route updates; the tool resolves method ids and validates that GET is available.',
1005
1005
  'publicMethods controls anonymous route access. Route permissions are not for public access.',
1006
1006
  'Route permissions apply when the method is not public.',
1007
1007
  ],
@@ -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: `create_api_endpoint`; route tools such as `add_route_methods`, `publish_route_methods`, and `unpublish_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 `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.',
@@ -42,7 +42,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
42
42
  '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
43
43
  '- Use `enfyra_user` as the user table. Model record links as real relations using relation `propertyName` values, not physical FK fields like `userId`, `conversationId`, `senderId`, or `memberId` in generated DB code.',
44
44
  '- Do not call internal/no-route system tables such as `enfyra_column` or `enfyra_session` through generic CRUD. Use table/column/relation tools and route-backed tables discovered from metadata.',
45
- '- Custom API paths use `create_api_endpoint` when a handler is needed. Use lower-level `create_route` without `mainTableId` only when intentionally creating a route shell; `create_table` is only for new persisted data.',
45
+ '- Custom API paths use `api_endpoint_workflow` when a handler is needed and the model should follow returned nextSteps. Use lower-level `create_route` without `mainTableId` only when intentionally creating a route shell; `create_table` is only for new persisted data.',
46
46
  '- For canonical table reads and RLS, preserve client-controlled query shape: do not override `@QUERY.fields`, `@QUERY.deep`, `@QUERY.sort`, `@QUERY.limit`, `@QUERY.page`, `@QUERY.meta`, `@QUERY.aggregate`, or `debugMode`. Merge only security filters into `@QUERY.filter`.',
47
47
  '- Script source is `sourceCode`; `compiledCode` is generated and may differ textually because macros expand. Do not warn about source/compiled mismatch unless validation or runtime behavior proves the compiled artifact is stale.',
48
48
  '- For intentional user/domain errors in scripts use `@THROW400`-style helpers or `$ctx.$throw[...]`, not `throw new Error(...)`.',
@@ -456,6 +456,303 @@ async function ensureFlowStep(apiUrl, {
456
456
  return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
457
457
  }
458
458
 
459
+ function normalizeEndpointAccess(anonymousAccess, makePublic) {
460
+ if (makePublic !== undefined) return makePublic ? 'public' : 'private';
461
+ return anonymousAccess || 'private';
462
+ }
463
+
464
+ function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
465
+ if (!existingHandler) return false;
466
+ if (String(existingHandler.sourceCode ?? '') !== String(sourceCode ?? '')) return false;
467
+ if (scriptLanguage && String(existingHandler.scriptLanguage || 'javascript') !== String(scriptLanguage)) return false;
468
+ if (timeout !== undefined && Number(existingHandler.timeout) !== Number(timeout)) return false;
469
+ return true;
470
+ }
471
+
472
+ function step(status, id, title, detail = {}) {
473
+ return { id, title, status, ...detail };
474
+ }
475
+
476
+ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
477
+ const normalizedPath = normalizeRestPath(opts.path);
478
+ const methodName = normalizeMethodName(opts.method);
479
+ const access = normalizeEndpointAccess(opts.anonymousAccess, opts.public);
480
+ const { methodMap, methodIdNameMap } = await getMethodContext(apiUrl);
481
+ const methodId = methodMap[methodName];
482
+ if (!methodId) throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
483
+
484
+ const [routes, scriptValidation] = await Promise.all([
485
+ fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,description,availableMethods.*,publicMethods.*,mainTable.name'),
486
+ validateScriptSourceIfPresent(fetchAPI, apiUrl, 'enfyra_route_handler', {
487
+ sourceCode: opts.sourceCode,
488
+ scriptLanguage: opts.scriptLanguage || 'javascript',
489
+ }),
490
+ ]);
491
+
492
+ const route = routes.find((item) => item.path === normalizedPath) || null;
493
+ const routeId = getId(route);
494
+ const availableMethods = methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
495
+ const publicMethods = methodNamesFromRecords(route?.publicMethods || [], methodIdNameMap);
496
+ const routeNeedsUpdate = !!route && (
497
+ route.isEnabled === false
498
+ || !availableMethods.includes(methodName)
499
+ || (access === 'public' && !publicMethods.includes(methodName))
500
+ || (access === 'private' && publicMethods.includes(methodName))
501
+ || (opts.description !== undefined && route.description !== opts.description)
502
+ );
503
+ const handler = route ? await findHandler(apiUrl, routeId, methodId) : null;
504
+ const handlerMatches = sourceMatches(handler, opts.sourceCode, opts.scriptLanguage || 'javascript', opts.timeout);
505
+ const handlerNeedsOverwrite = !!handler && !handlerMatches;
506
+
507
+ let permission = null;
508
+ let role = null;
509
+ let permissionMethods = [];
510
+ let permissionMissingMethods = [];
511
+ if (opts.roleName || opts.roleId || opts.allowedUserIds?.length) {
512
+ if (access === 'public') {
513
+ permissionMissingMethods = [];
514
+ } else if (!route) {
515
+ permissionMissingMethods = [methodName];
516
+ } else {
517
+ const permissions = await fetchRecords(apiUrl, 'enfyra_route_permission', {
518
+ route: { id: { _eq: routeId } },
519
+ }, 'id,_id,route.id,role.id,role.name,allowedUsers.id,methods.*', 1000);
520
+ role = await resolveRole(apiUrl, { roleId: opts.roleId, roleName: opts.roleName });
521
+ const allowedUserIds = (opts.allowedUserIds || []).map(String).sort();
522
+ permission = permissions.find((candidate) => {
523
+ const candidateRoleId = refId(candidate.role);
524
+ const candidateUserIds = (candidate.allowedUsers || []).map((item) => String(refId(item))).sort();
525
+ if (role && String(candidateRoleId) !== String(role.id)) return false;
526
+ if (!role && candidateRoleId !== null && candidateRoleId !== undefined) return false;
527
+ return allowedUserIds.length === candidateUserIds.length
528
+ && allowedUserIds.every((value, index) => value === candidateUserIds[index]);
529
+ }) || null;
530
+ permissionMethods = methodNamesFromRecords(permission?.methods || [], methodIdNameMap);
531
+ permissionMissingMethods = permissionMethods.includes(methodName) ? [] : [methodName];
532
+ }
533
+ }
534
+
535
+ const smokeTestRequested = opts.smokeTestQuery !== undefined || opts.smokeTestBody !== undefined;
536
+ const steps = [
537
+ route
538
+ ? step(routeNeedsUpdate ? 'pending' : 'completed', 'sync_route', 'Ensure route method and public access', {
539
+ routeId,
540
+ availableMethods,
541
+ publicMethods,
542
+ desiredAccess: access,
543
+ })
544
+ : step('pending', 'create_route', 'Create custom route', {
545
+ desiredAccess: access,
546
+ }),
547
+ handler
548
+ ? step(handlerNeedsOverwrite ? (opts.overwrite ? 'pending' : 'blocked') : 'completed', 'save_handler', 'Create or update route handler', {
549
+ handlerId: getId(handler),
550
+ reason: handlerNeedsOverwrite && !opts.overwrite ? 'Existing handler differs. Re-run with overwrite=true to update it.' : undefined,
551
+ })
552
+ : step(route ? 'pending' : 'waiting', 'save_handler', 'Create route handler', {
553
+ reason: route ? undefined : 'Route must exist first.',
554
+ }),
555
+ ];
556
+
557
+ if (opts.roleName || opts.roleId || opts.allowedUserIds?.length) {
558
+ steps.push(
559
+ access === 'public'
560
+ ? step('skipped', 'ensure_route_access', 'Ensure authenticated route access', {
561
+ reason: 'Method is public, so route permission is not required for anonymous access.',
562
+ })
563
+ : step(permissionMissingMethods.length ? (route ? 'pending' : 'waiting') : 'completed', 'ensure_route_access', 'Ensure authenticated route access', {
564
+ permissionId: getId(permission),
565
+ role,
566
+ allowedUserIds: opts.allowedUserIds || [],
567
+ methods: permissionMethods,
568
+ missingMethods: permissionMissingMethods,
569
+ }),
570
+ );
571
+ }
572
+
573
+ if (smokeTestRequested) {
574
+ const blockers = steps.filter((item) => ['pending', 'waiting', 'blocked'].includes(item.status));
575
+ steps.push(step(blockers.length ? 'waiting' : 'pending', 'smoke_test', 'Smoke-test the endpoint', {
576
+ reason: blockers.length ? 'Endpoint must be ready before smoke test.' : undefined,
577
+ }));
578
+ }
579
+
580
+ const firstRunnable = steps.find((item) => item.status === 'pending') || null;
581
+ const blocked = steps.find((item) => item.status === 'blocked') || null;
582
+
583
+ return {
584
+ endpoint: {
585
+ path: normalizedPath,
586
+ method: methodName,
587
+ anonymousAccess: access,
588
+ routeId,
589
+ handlerId: getId(handler),
590
+ },
591
+ methodId,
592
+ methodMap,
593
+ methodIdNameMap,
594
+ route,
595
+ handler,
596
+ role,
597
+ scriptValidation,
598
+ steps,
599
+ firstRunnable,
600
+ blocked,
601
+ nextSteps: blocked
602
+ ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, overwrite: true }, reason: blocked.reason }]
603
+ : firstRunnable
604
+ ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, apply: true }, stepId: firstRunnable.id }]
605
+ : [],
606
+ };
607
+ }
608
+
609
+ async function applyApiEndpointWorkflowStep(apiUrl, state, opts, stepId) {
610
+ const selectedStep = stepId
611
+ ? state.steps.find((item) => item.id === stepId)
612
+ : state.firstRunnable;
613
+ if (!selectedStep) return { action: 'noop', reason: 'No runnable step remains.' };
614
+ if (selectedStep.status !== 'pending') {
615
+ throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
616
+ }
617
+
618
+ const endpoint = state.endpoint;
619
+ if (selectedStep.id === 'create_route') {
620
+ const result = await fetchAPI(apiUrl, '/enfyra_route', {
621
+ method: 'POST',
622
+ body: JSON.stringify({
623
+ path: endpoint.path,
624
+ description: opts.description,
625
+ isEnabled: true,
626
+ availableMethods: [{ id: state.methodId }],
627
+ publicMethods: endpoint.anonymousAccess === 'public' ? [{ id: state.methodId }] : [],
628
+ }),
629
+ });
630
+ return { action: 'route_created', result, routeReload: await reloadRoutes(apiUrl) };
631
+ }
632
+
633
+ if (selectedStep.id === 'sync_route') {
634
+ const availableMethods = methodNamesFromRecords(state.route.availableMethods, state.methodIdNameMap);
635
+ const publicMethods = methodNamesFromRecords(state.route.publicMethods, state.methodIdNameMap);
636
+ const finalAvailable = uniqueMethodNames([...availableMethods, endpoint.method]);
637
+ const finalPublic = endpoint.anonymousAccess === 'public'
638
+ ? uniqueMethodNames([...publicMethods, endpoint.method])
639
+ : publicMethods.filter((method) => method !== endpoint.method);
640
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(endpoint.routeId))}`, {
641
+ method: 'PATCH',
642
+ body: JSON.stringify({
643
+ isEnabled: true,
644
+ availableMethods: resolveMethodRefs(state.methodMap, finalAvailable),
645
+ publicMethods: resolveMethodRefs(state.methodMap, finalPublic),
646
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
647
+ }),
648
+ });
649
+ return { action: 'route_synced', result, routeReload: await reloadRoutes(apiUrl) };
650
+ }
651
+
652
+ if (selectedStep.id === 'save_handler') {
653
+ if (!endpoint.routeId) throw new Error('Route must exist before saving handler.');
654
+ const body = {
655
+ sourceCode: opts.sourceCode,
656
+ scriptLanguage: opts.scriptLanguage || 'javascript',
657
+ ...(opts.timeout !== undefined ? { timeout: opts.timeout } : {}),
658
+ };
659
+ if (state.handler) {
660
+ const result = await fetchAPI(apiUrl, `/enfyra_route_handler/${encodeURIComponent(String(getId(state.handler)))}`, {
661
+ method: 'PATCH',
662
+ body: JSON.stringify(body),
663
+ });
664
+ return { action: 'handler_updated', result, routeReload: await reloadRoutes(apiUrl) };
665
+ }
666
+ const result = await fetchAPI(apiUrl, '/enfyra_route_handler', {
667
+ method: 'POST',
668
+ body: JSON.stringify({
669
+ route: { id: endpoint.routeId },
670
+ method: { id: state.methodId },
671
+ ...body,
672
+ }),
673
+ });
674
+ return { action: 'handler_created', result, routeReload: await reloadRoutes(apiUrl) };
675
+ }
676
+
677
+ if (selectedStep.id === 'ensure_route_access') {
678
+ assertOneScope(opts);
679
+ const role = state.role || await resolveRole(apiUrl, { roleId: opts.roleId, roleName: opts.roleName });
680
+ const existing = state.steps.find((item) => item.id === 'ensure_route_access')?.permissionId
681
+ ? await findRecord(apiUrl, 'enfyra_route_permission', { id: { _eq: state.steps.find((item) => item.id === 'ensure_route_access').permissionId } }, 'id,_id,methods.*')
682
+ : null;
683
+ const existingMethods = methodNamesFromRecords(existing?.methods || [], state.methodIdNameMap);
684
+ const finalMethods = uniqueMethodNames([...existingMethods, endpoint.method]);
685
+ const body = {
686
+ isEnabled: true,
687
+ description: opts.routePermissionDescription,
688
+ methods: resolveMethodRefs(state.methodMap, finalMethods),
689
+ ...(role ? { role: { id: role.id } } : {}),
690
+ ...(opts.allowedUserIds?.length ? { allowedUsers: opts.allowedUserIds.map((id) => ({ id })) } : {}),
691
+ };
692
+ const result = existing
693
+ ? await fetchAPI(apiUrl, `/enfyra_route_permission/${encodeURIComponent(String(getId(existing)))}`, {
694
+ method: 'PATCH',
695
+ body: JSON.stringify(body),
696
+ })
697
+ : await fetchAPI(apiUrl, '/enfyra_route_permission', {
698
+ method: 'POST',
699
+ body: JSON.stringify({
700
+ route: { id: endpoint.routeId },
701
+ ...body,
702
+ }),
703
+ });
704
+ return { action: existing ? 'route_access_updated' : 'route_access_created', result, routeReload: await reloadRoutes(apiUrl) };
705
+ }
706
+
707
+ if (selectedStep.id === 'smoke_test') {
708
+ const query = parseJsonObjectArg('smokeTestQuery', opts.smokeTestQuery, {});
709
+ const queryParams = new URLSearchParams();
710
+ for (const [key, value] of Object.entries(query)) {
711
+ if (value !== undefined && value !== null) queryParams.set(key, String(value));
712
+ }
713
+ const body = opts.smokeTestBody === undefined ? undefined : parseJsonObjectArg('smokeTestBody', opts.smokeTestBody, {});
714
+ const smokePath = `${endpoint.path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
715
+ const result = await fetchAPI(apiUrl, smokePath, {
716
+ method: endpoint.method,
717
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
718
+ });
719
+ return { action: 'smoke_test_passed', result };
720
+ }
721
+
722
+ throw new Error(`Unsupported workflow step: ${selectedStep.id}`);
723
+ }
724
+
725
+ async function runApiEndpointWorkflow(apiUrl, opts) {
726
+ let state = await resolveApiEndpointWorkflowState(apiUrl, opts);
727
+ const operations = [];
728
+ if (opts.apply || opts.applyAll) {
729
+ const maxSteps = opts.applyAll ? 10 : 1;
730
+ for (let i = 0; i < maxSteps; i += 1) {
731
+ if (state.blocked || !state.firstRunnable) break;
732
+ const operation = await applyApiEndpointWorkflowStep(apiUrl, state, opts, opts.stepId);
733
+ operations.push(operation);
734
+ if (!opts.applyAll) break;
735
+ state = await resolveApiEndpointWorkflowState(apiUrl, opts);
736
+ }
737
+ }
738
+ const latestState = operations.length ? await resolveApiEndpointWorkflowState(apiUrl, opts) : state;
739
+ return {
740
+ action: operations.length ? 'api_endpoint_workflow_advanced' : 'api_endpoint_workflow_planned',
741
+ endpoint: latestState.endpoint,
742
+ scriptValidation: latestState.scriptValidation,
743
+ steps: latestState.steps,
744
+ operations,
745
+ complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
746
+ nextSteps: latestState.nextSteps,
747
+ cleanupHints: latestState.endpoint.routeId
748
+ ? [
749
+ `Preview delete route handler with delete_record({ tableName: "enfyra_route_handler", id: ${JSON.stringify(latestState.endpoint.handlerId)} }) before cleanup.`,
750
+ `Preview delete route with delete_record({ tableName: "enfyra_route", id: ${JSON.stringify(latestState.endpoint.routeId)} }) only when no longer needed.`,
751
+ ]
752
+ : [],
753
+ };
754
+ }
755
+
459
756
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
460
757
  server.tool(
461
758
  'validate_dynamic_script',
@@ -570,12 +867,12 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
570
867
  );
571
868
 
572
869
  server.tool(
573
- 'publish_route_methods',
870
+ 'public_route_methods',
574
871
  'Business operation: make existing route methods public/anonymous.',
575
872
  {
576
873
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
577
874
  routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
578
- methods: z.array(z.string()).min(1).describe('HTTP method names to publish. They must already be available on the route.'),
875
+ methods: z.array(z.string()).min(1).describe('HTTP method names to make public. They must already be available on the route.'),
579
876
  },
580
877
  async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
581
878
  path,
@@ -586,7 +883,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
586
883
  );
587
884
 
588
885
  server.tool(
589
- 'replace_public_route_methods',
886
+ 'set_public_route_methods',
590
887
  'Business operation: replace a route publicMethods list exactly.',
591
888
  {
592
889
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
@@ -602,7 +899,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
602
899
  );
603
900
 
604
901
  server.tool(
605
- 'unpublish_route_methods',
902
+ 'private_route_methods',
606
903
  'Business operation: make specific public route methods private again.',
607
904
  {
608
905
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
@@ -617,12 +914,43 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
617
914
  })),
618
915
  );
619
916
 
917
+ server.tool(
918
+ 'api_endpoint_workflow',
919
+ [
920
+ 'Step-by-step workflow for creating or updating a custom REST endpoint.',
921
+ 'Use this when an LLM is building or changing endpoint behavior and should follow live nextSteps instead of guessing raw metadata mutations.',
922
+ 'With apply=false it validates sourceCode, reads live route/handler/access state, and returns pending steps.',
923
+ 'With apply=true it applies only the next pending step, then returns a fresh plan. With applyAll=true it advances all currently safe pending steps.',
924
+ ].join(' '),
925
+ {
926
+ path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
927
+ method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
928
+ sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER. Do not send compiledCode.'),
929
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
930
+ anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
931
+ public: z.boolean().optional().describe('Compatibility alias for anonymousAccess. true means public, false means private.'),
932
+ roleId: z.union([z.string(), z.number()]).optional().describe('Optional role id for authenticated route permission.'),
933
+ roleName: z.string().optional().describe('Optional role name for authenticated route permission, e.g. user.'),
934
+ allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Optional user id scope for authenticated route permission.'),
935
+ routePermissionDescription: z.string().optional().describe('Optional admin note for created/updated route permission.'),
936
+ description: z.string().optional().describe('Route description.'),
937
+ timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
938
+ overwrite: z.boolean().optional().default(false).describe('Required to update an existing handler whose sourceCode/scriptLanguage/timeout differs.'),
939
+ smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test, e.g. {"a":"1","b":"2"}.'),
940
+ smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test.'),
941
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
942
+ applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
943
+ stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
944
+ },
945
+ async (input) => jsonText(await runApiEndpointWorkflow(ENFYRA_API_URL, input)),
946
+ );
947
+
620
948
  server.tool(
621
949
  'create_api_endpoint',
622
950
  [
623
951
  'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
624
952
  'Use this when the user asks for a new route/endpoint/API path that computes or orchestrates behavior, such as GET /sum or POST /webhook.',
625
- 'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally publishes the method, reloads routes, and can smoke-test the endpoint.',
953
+ 'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally makes the method public, reloads routes, and can smoke-test the endpoint.',
626
954
  'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
627
955
  ].join(' '),
628
956
  {