@enfyra/mcp-server 0.1.8 → 0.1.9
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 +1 -1
- package/package.json +1 -1
- package/src/lib/platform-operation-tools.js +229 -0
- package/src/lib/tool-routing.js +4 -3
package/README.md
CHANGED
|
@@ -265,7 +265,7 @@ The MCP server includes safety guards for LLM callers:
|
|
|
265
265
|
- Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
|
|
266
266
|
- Custom route tools reject `mainTableId` unless the route is the canonical table route.
|
|
267
267
|
- `discover_enfyra_workflows` maps task intent to workflow surfaces before the agent loads detailed examples or guesses between similar tools.
|
|
268
|
-
- 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`, `choose_flow_step_tool`, fixed-type flow step tools, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
|
|
268
|
+
- Platform operation tools such as `api_endpoint_workflow`, `extension_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`, `choose_flow_step_tool`, fixed-type flow step tools, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
|
|
269
269
|
- Schema changes are serialized.
|
|
270
270
|
- Destructive deletes return a preview before requiring `confirm=true`.
|
|
271
271
|
|
package/package.json
CHANGED
|
@@ -873,6 +873,18 @@ function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
|
|
|
873
873
|
return true;
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
function extensionMatches(existingExtension, opts, menuId) {
|
|
877
|
+
if (!existingExtension) return false;
|
|
878
|
+
if (String(existingExtension.type || '') !== String(opts.type || 'page')) return false;
|
|
879
|
+
if (String(existingExtension.code ?? '') !== String(opts.code ?? '')) return false;
|
|
880
|
+
if (opts.description !== undefined && String(existingExtension.description || '') !== String(opts.description || '')) return false;
|
|
881
|
+
if (opts.isEnabled !== undefined && Boolean(existingExtension.isEnabled) !== Boolean(opts.isEnabled)) return false;
|
|
882
|
+
if (opts.version !== undefined && String(existingExtension.version || '') !== String(opts.version)) return false;
|
|
883
|
+
if ((opts.type || 'page') === 'page' && menuId && String(refId(existingExtension.menu)) !== String(menuId)) return false;
|
|
884
|
+
if ((opts.type || 'page') !== 'page' && refId(existingExtension.menu)) return false;
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
|
|
876
888
|
function step(status, id, title, detail = {}) {
|
|
877
889
|
return { id, title, status, ...detail };
|
|
878
890
|
}
|
|
@@ -1183,6 +1195,189 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
|
|
|
1183
1195
|
};
|
|
1184
1196
|
}
|
|
1185
1197
|
|
|
1198
|
+
async function resolveExtensionWorkflowState(apiUrl, opts) {
|
|
1199
|
+
const type = opts.type || 'page';
|
|
1200
|
+
if (type === 'page' && opts.menuId && (opts.menuLabel || opts.menuPath)) {
|
|
1201
|
+
throw new Error('Provide menuId or menuLabel/menuPath for page extension workflow, not both.');
|
|
1202
|
+
}
|
|
1203
|
+
if (type !== 'page' && (opts.menuId || opts.menuLabel || opts.menuPath)) {
|
|
1204
|
+
throw new Error('Menu fields are only valid for page extensions.');
|
|
1205
|
+
}
|
|
1206
|
+
const validation = await validateExtensionCode(apiUrl, opts.code, opts.name);
|
|
1207
|
+
const existingExtension = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: opts.name } }, 'id,_id,name,type,menu.id,description,isEnabled,version,code');
|
|
1208
|
+
let menu = null;
|
|
1209
|
+
if (type === 'page' && opts.menuId) {
|
|
1210
|
+
menu = await findRecord(apiUrl, 'enfyra_menu', { id: { _eq: opts.menuId } }, 'id,_id,label,path,type,order,isEnabled');
|
|
1211
|
+
if (!menu) throw new Error(`Menu not found: ${opts.menuId}`);
|
|
1212
|
+
} else if (type === 'page' && (opts.menuPath || opts.menuLabel)) {
|
|
1213
|
+
const normalizedPath = opts.menuPath ? normalizeRestPath(opts.menuPath) : undefined;
|
|
1214
|
+
menu = normalizedPath
|
|
1215
|
+
? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,label,path,type,order,isEnabled')
|
|
1216
|
+
: await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: opts.menuLabel } }, 'id,_id,label,path,type,order,isEnabled');
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const menuId = opts.menuId || getId(menu);
|
|
1220
|
+
const steps = [];
|
|
1221
|
+
steps.push(step('completed', 'validate_extension', 'Validate extension code', { validation }));
|
|
1222
|
+
if (type === 'page') {
|
|
1223
|
+
if (menuId) {
|
|
1224
|
+
const menuNeedsUpdate = Boolean(menu && (
|
|
1225
|
+
(opts.menuLabel !== undefined && menu.label !== opts.menuLabel)
|
|
1226
|
+
|| (opts.menuPath !== undefined && menu.path !== normalizeRestPath(opts.menuPath))
|
|
1227
|
+
|| (opts.menuType !== undefined && menu.type !== opts.menuType)
|
|
1228
|
+
|| (opts.menuOrder !== undefined && Number(menu.order || 0) !== Number(opts.menuOrder))
|
|
1229
|
+
|| (opts.menuIsEnabled !== undefined && Boolean(menu.isEnabled) !== Boolean(opts.menuIsEnabled))
|
|
1230
|
+
));
|
|
1231
|
+
steps.push(step(menuNeedsUpdate ? 'pending' : 'completed', 'ensure_menu', 'Ensure page menu', {
|
|
1232
|
+
menuId,
|
|
1233
|
+
menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : { id: menuId },
|
|
1234
|
+
}));
|
|
1235
|
+
} else if (opts.menuLabel) {
|
|
1236
|
+
steps.push(step('pending', 'ensure_menu', 'Create page menu', {
|
|
1237
|
+
reason: 'No existing menu matched; ensure_menu will create it.',
|
|
1238
|
+
}));
|
|
1239
|
+
} else {
|
|
1240
|
+
steps.push(step('blocked', 'ensure_menu', 'Create or select page menu', {
|
|
1241
|
+
reason: 'Page extensions require menuId or menuLabel. Provide menuId for an existing menu or menuLabel/menuPath to create/update one.',
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const effectiveMenuId = type === 'page' ? menuId : undefined;
|
|
1247
|
+
const saveStatus = steps.some((item) => ['blocked', 'waiting'].includes(item.status))
|
|
1248
|
+
? 'waiting'
|
|
1249
|
+
: extensionMatches(existingExtension, { ...opts, type }, effectiveMenuId)
|
|
1250
|
+
? 'completed'
|
|
1251
|
+
: 'pending';
|
|
1252
|
+
steps.push(step(saveStatus, 'save_extension', `Ensure ${type} extension`, {
|
|
1253
|
+
extensionId: getId(existingExtension),
|
|
1254
|
+
currentType: existingExtension?.type || null,
|
|
1255
|
+
desiredType: type,
|
|
1256
|
+
menuId: effectiveMenuId || null,
|
|
1257
|
+
reason: saveStatus === 'waiting' ? 'Menu must exist before saving page extension.' : undefined,
|
|
1258
|
+
}));
|
|
1259
|
+
|
|
1260
|
+
const firstRunnable = steps.find((item) => item.status === 'pending') || null;
|
|
1261
|
+
const blocked = steps.find((item) => item.status === 'blocked') || null;
|
|
1262
|
+
return {
|
|
1263
|
+
extension: {
|
|
1264
|
+
name: opts.name,
|
|
1265
|
+
type,
|
|
1266
|
+
id: getId(existingExtension),
|
|
1267
|
+
menuId: effectiveMenuId || null,
|
|
1268
|
+
},
|
|
1269
|
+
validation,
|
|
1270
|
+
existingExtension: existingExtension ? {
|
|
1271
|
+
id: getId(existingExtension),
|
|
1272
|
+
name: existingExtension.name,
|
|
1273
|
+
type: existingExtension.type,
|
|
1274
|
+
menuId: refId(existingExtension.menu) || null,
|
|
1275
|
+
} : null,
|
|
1276
|
+
menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : null,
|
|
1277
|
+
steps,
|
|
1278
|
+
firstRunnable,
|
|
1279
|
+
blocked,
|
|
1280
|
+
nextSteps: blocked
|
|
1281
|
+
? [{ tool: 'extension_workflow', input: { name: opts.name, type }, reason: blocked.reason }]
|
|
1282
|
+
: firstRunnable
|
|
1283
|
+
? [{
|
|
1284
|
+
tool: 'extension_workflow',
|
|
1285
|
+
input: { name: opts.name, type, apply: true, stepId: firstRunnable.id },
|
|
1286
|
+
stepId: firstRunnable.id,
|
|
1287
|
+
requiresKnowledgeAck: 'globalRulesAckKey and extensionAckKey from get_enfyra_required_knowledge',
|
|
1288
|
+
}]
|
|
1289
|
+
: [],
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function applyExtensionWorkflowStep(apiUrl, state, opts, stepId) {
|
|
1294
|
+
const selectedStep = stepId
|
|
1295
|
+
? state.steps.find((item) => item.id === stepId)
|
|
1296
|
+
: state.firstRunnable;
|
|
1297
|
+
if (!selectedStep) return { action: 'noop', reason: 'No runnable step remains.' };
|
|
1298
|
+
if (selectedStep.status !== 'pending') {
|
|
1299
|
+
throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const type = opts.type || 'page';
|
|
1303
|
+
if (selectedStep.id === 'ensure_menu') {
|
|
1304
|
+
if (type !== 'page') throw new Error('ensure_menu step is only valid for page extensions.');
|
|
1305
|
+
if (!opts.menuLabel && !opts.menuId) throw new Error('menuLabel or menuId is required for ensure_menu.');
|
|
1306
|
+
return {
|
|
1307
|
+
action: 'menu_ensured',
|
|
1308
|
+
menu: await ensureMenu(apiUrl, {
|
|
1309
|
+
label: opts.menuLabel || state.menu?.label || opts.name,
|
|
1310
|
+
path: opts.menuPath || state.menu?.path,
|
|
1311
|
+
icon: opts.menuIcon,
|
|
1312
|
+
type: opts.menuType,
|
|
1313
|
+
order: opts.menuOrder,
|
|
1314
|
+
permission: opts.menuPermission,
|
|
1315
|
+
description: opts.menuDescription,
|
|
1316
|
+
isEnabled: opts.menuIsEnabled,
|
|
1317
|
+
globalRulesAckKey: opts.globalRulesAckKey,
|
|
1318
|
+
}),
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (selectedStep.id === 'save_extension') {
|
|
1323
|
+
let menuId = opts.menuId || state.extension.menuId;
|
|
1324
|
+
if (type === 'page' && !menuId) {
|
|
1325
|
+
const freshState = await resolveExtensionWorkflowState(apiUrl, opts);
|
|
1326
|
+
menuId = freshState.extension.menuId;
|
|
1327
|
+
}
|
|
1328
|
+
if (type === 'page' && !menuId) throw new Error('Page extension menu is missing. Apply ensure_menu first.');
|
|
1329
|
+
return {
|
|
1330
|
+
action: `${type}_extension_ensured`,
|
|
1331
|
+
extension: await ensureExtension(apiUrl, {
|
|
1332
|
+
name: opts.name,
|
|
1333
|
+
type,
|
|
1334
|
+
code: opts.code,
|
|
1335
|
+
menuId,
|
|
1336
|
+
description: opts.description,
|
|
1337
|
+
isEnabled: opts.isEnabled,
|
|
1338
|
+
version: opts.version,
|
|
1339
|
+
globalRulesAckKey: opts.globalRulesAckKey,
|
|
1340
|
+
extensionKnowledgeAckKey: opts.extensionKnowledgeAckKey,
|
|
1341
|
+
}),
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
throw new Error(`Unsupported extension workflow step: ${selectedStep.id}`);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
async function runExtensionWorkflow(apiUrl, opts) {
|
|
1349
|
+
let state = await resolveExtensionWorkflowState(apiUrl, opts);
|
|
1350
|
+
const operations = [];
|
|
1351
|
+
if (opts.apply || opts.applyAll) {
|
|
1352
|
+
assertGlobalRulesAck(opts.globalRulesAckKey);
|
|
1353
|
+
assertExtensionKnowledgeAck(opts.extensionKnowledgeAckKey);
|
|
1354
|
+
const maxSteps = opts.applyAll ? 5 : 1;
|
|
1355
|
+
for (let i = 0; i < maxSteps; i += 1) {
|
|
1356
|
+
if (state.blocked || !state.firstRunnable) break;
|
|
1357
|
+
operations.push(await applyExtensionWorkflowStep(apiUrl, state, opts, opts.stepId));
|
|
1358
|
+
if (!opts.applyAll) break;
|
|
1359
|
+
state = await resolveExtensionWorkflowState(apiUrl, opts);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
const latestState = operations.length ? await resolveExtensionWorkflowState(apiUrl, opts) : state;
|
|
1363
|
+
return {
|
|
1364
|
+
action: operations.length ? 'extension_workflow_advanced' : 'extension_workflow_planned',
|
|
1365
|
+
extension: latestState.extension,
|
|
1366
|
+
validation: latestState.validation,
|
|
1367
|
+
menu: latestState.menu,
|
|
1368
|
+
existingExtension: latestState.existingExtension,
|
|
1369
|
+
steps: latestState.steps,
|
|
1370
|
+
operations,
|
|
1371
|
+
complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
|
|
1372
|
+
nextSteps: latestState.nextSteps,
|
|
1373
|
+
guidance: [
|
|
1374
|
+
'Call get_extension_theme_contract before generating or reviewing extension UI.',
|
|
1375
|
+
'For menu/account-panel notifications, use counts only when the signal source already owns an exact count; otherwise use a dot/chip for new attention.',
|
|
1376
|
+
'Do not fetch destination domain lists solely to decorate the shell; destination pages own domain fetching after click.',
|
|
1377
|
+
],
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1186
1381
|
export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
1187
1382
|
server.tool(
|
|
1188
1383
|
'validate_dynamic_script',
|
|
@@ -1236,6 +1431,40 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
1236
1431
|
async () => jsonText(getThemeClassReference()),
|
|
1237
1432
|
);
|
|
1238
1433
|
|
|
1434
|
+
server.tool(
|
|
1435
|
+
'extension_workflow',
|
|
1436
|
+
[
|
|
1437
|
+
'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
|
|
1438
|
+
'Use this when an LLM is building extension UI, menu shell notifications, account panel entries, or page/menu wiring and should follow live nextSteps instead of guessing raw enfyra_extension mutations.',
|
|
1439
|
+
'With apply=false it validates code, reads live menu/extension state, and returns pending steps.',
|
|
1440
|
+
'With apply=true it applies exactly the next pending step. With applyAll=true it advances all currently safe pending steps.',
|
|
1441
|
+
'Call get_extension_theme_contract before generating or reviewing UI.',
|
|
1442
|
+
].join(' '),
|
|
1443
|
+
{
|
|
1444
|
+
name: z.string().describe('Extension unique name.'),
|
|
1445
|
+
type: z.enum(['page', 'global', 'widget']).optional().default('page').describe('Extension type. Page extensions need a menu. Global extensions are for shell-wide registration.'),
|
|
1446
|
+
code: z.string().describe('Vue SFC extension code.'),
|
|
1447
|
+
menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
|
|
1448
|
+
menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
|
|
1449
|
+
menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
|
|
1450
|
+
menuIcon: z.string().optional().describe('Optional menu icon name.'),
|
|
1451
|
+
menuType: z.enum(['Menu', 'Dropdown Menu']).optional().describe('Menu type. Omit to preserve an existing menu value or use the platform default for a new menu.'),
|
|
1452
|
+
menuOrder: z.number().optional().describe('Menu display order. Omit to preserve an existing menu value or use the platform default for a new menu.'),
|
|
1453
|
+
menuPermission: z.string().optional().describe('Optional menu permission JSON object.'),
|
|
1454
|
+
menuDescription: z.string().optional().describe('Optional menu admin note.'),
|
|
1455
|
+
menuIsEnabled: z.boolean().optional().describe('Enable the menu. Omit to preserve an existing menu value or use the platform default for a new menu.'),
|
|
1456
|
+
description: z.string().optional().describe('Extension description.'),
|
|
1457
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
1458
|
+
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
1459
|
+
apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
|
|
1460
|
+
applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
|
|
1461
|
+
stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
|
|
1462
|
+
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
1463
|
+
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required when apply/applyAll saves extension code. Use extensionAckKey from get_enfyra_required_knowledge.'),
|
|
1464
|
+
},
|
|
1465
|
+
async (input) => jsonText(await runExtensionWorkflow(ENFYRA_API_URL, input)),
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1239
1468
|
server.tool(
|
|
1240
1469
|
'set_table_graphql',
|
|
1241
1470
|
'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
|
package/src/lib/tool-routing.js
CHANGED
|
@@ -67,14 +67,14 @@ export const TOOL_WORKFLOWS = [
|
|
|
67
67
|
firstTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'inspect_feature'],
|
|
68
68
|
inspectTools: ['inspect_feature', 'trace_metadata_usage', 'get_script_source'],
|
|
69
69
|
knowledgeTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'get_theme_class_reference'],
|
|
70
|
-
writeTools: ['ensure_menu', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
|
|
70
|
+
writeTools: ['extension_workflow', 'ensure_menu', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
|
|
71
71
|
verifyTools: ['validate_extension_code', 'inspect_feature'],
|
|
72
72
|
avoidTools: [
|
|
73
73
|
{
|
|
74
74
|
tool: 'create_record/update_record on enfyra_extension',
|
|
75
75
|
when: 'creating or changing extension code',
|
|
76
|
-
useInstead: 'ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
|
|
77
|
-
reason: '
|
|
76
|
+
useInstead: 'extension_workflow, ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
|
|
77
|
+
reason: 'Workflow and ensure tools validate extension code and preserve extension/menu contracts before saving.',
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
tool: 'query_table on destination domain lists',
|
|
@@ -88,6 +88,7 @@ export const TOOL_WORKFLOWS = [
|
|
|
88
88
|
nextStepTemplate: [
|
|
89
89
|
'Call get_extension_theme_contract before writing or reviewing UI.',
|
|
90
90
|
'Inspect the existing menu/extension/global shell registration.',
|
|
91
|
+
'Use extension_workflow with apply=false when page/menu wiring or shell notification behavior needs multiple steps.',
|
|
91
92
|
'Choose count only when the source already owns an exact count; choose dot/chip for new-attention signals.',
|
|
92
93
|
'Validate extension code or use an ensure_*_extension tool that validates before saving.',
|
|
93
94
|
],
|