@enfyra/mcp-server 0.0.99 → 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.99",
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(...)`.',
@@ -208,6 +208,10 @@ async function reloadBestEffort(apiUrl, path) {
208
208
  }
209
209
  }
210
210
 
211
+ function naturalPartialReload(reason) {
212
+ return { attempted: false, succeeded: true, reason };
213
+ }
214
+
211
215
  async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javascript') {
212
216
  const result = await fetchAPI(apiUrl, '/admin/script/validate', {
213
217
  method: 'POST',
@@ -408,7 +412,7 @@ async function ensureFlow(apiUrl, {
408
412
  isEnabled,
409
413
  description,
410
414
  });
411
- const reload = await reloadBestEffort(apiUrl, '/admin/reload/flows');
415
+ const reload = naturalPartialReload('Flow metadata writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
412
416
  return { action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload };
413
417
  }
414
418
 
@@ -448,10 +452,307 @@ async function ensureFlowStep(apiUrl, {
448
452
  timeout,
449
453
  isEnabled,
450
454
  }, getId(flow)));
451
- const reload = await reloadBestEffort(apiUrl, '/admin/reload/flows');
455
+ const reload = naturalPartialReload('Flow step writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
452
456
  return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
453
457
  }
454
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
+
455
756
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
456
757
  server.tool(
457
758
  'validate_dynamic_script',
@@ -566,12 +867,12 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
566
867
  );
567
868
 
568
869
  server.tool(
569
- 'publish_route_methods',
870
+ 'public_route_methods',
570
871
  'Business operation: make existing route methods public/anonymous.',
571
872
  {
572
873
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
573
874
  routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
574
- 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.'),
575
876
  },
576
877
  async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
577
878
  path,
@@ -582,7 +883,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
582
883
  );
583
884
 
584
885
  server.tool(
585
- 'replace_public_route_methods',
886
+ 'set_public_route_methods',
586
887
  'Business operation: replace a route publicMethods list exactly.',
587
888
  {
588
889
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
@@ -598,7 +899,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
598
899
  );
599
900
 
600
901
  server.tool(
601
- 'unpublish_route_methods',
902
+ 'private_route_methods',
602
903
  'Business operation: make specific public route methods private again.',
603
904
  {
604
905
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
@@ -613,12 +914,43 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
613
914
  })),
614
915
  );
615
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
+
616
948
  server.tool(
617
949
  'create_api_endpoint',
618
950
  [
619
951
  'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
620
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.',
621
- '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.',
622
954
  'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
623
955
  ].join(' '),
624
956
  {
@@ -954,7 +1286,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
954
1286
  ...(sourceCode !== undefined ? { sourceCode, scriptLanguage } : {}),
955
1287
  };
956
1288
  const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
957
- const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
1289
+ const reload = naturalPartialReload('Websocket metadata writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
958
1290
  return jsonText({ action: 'websocket_gateway_ensured', gateway: { id: operation.id, path: normalizedPath }, validation, operation, reload });
959
1291
  },
960
1292
  );
@@ -991,7 +1323,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
991
1323
  isEnabled,
992
1324
  description,
993
1325
  });
994
- const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
1326
+ const reload = naturalPartialReload('Websocket event writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
995
1327
  return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
996
1328
  },
997
1329
  );