@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`, `
|
|
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
package/src/lib/mcp-examples.js
CHANGED
|
@@ -995,13 +995,13 @@ ensure_route_access({
|
|
|
995
995
|
],
|
|
996
996
|
},
|
|
997
997
|
{
|
|
998
|
-
name: '
|
|
999
|
-
code: `
|
|
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
|
|
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
|
|
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 `
|
|
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 =
|
|
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 =
|
|
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
|
-
'
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
);
|