@enfyra/mcp-server 0.0.106 → 0.0.107

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
@@ -188,7 +188,7 @@ The MCP server includes safety guards for LLM callers:
188
188
  - `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
189
189
  - `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
190
190
  - JSON responses include `compressionStats` with estimated token savings. Arrays of objects are converted to columnar form only when the compact shape is smaller than raw JSON.
191
- - Relation tools reject physical FK/junction names.
191
+ - Relation tools reject physical FK/junction names and resolve table ids from exact table names or aliases before schema mutation.
192
192
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
193
193
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
194
194
  - Platform operation tools such as `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_script_flow_step`, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
@@ -197,7 +197,7 @@ The MCP server includes safety guards for LLM callers:
197
197
 
198
198
  ## Query Notes
199
199
 
200
- Use explicit `fields` in read tools. Include mode is the default, such as `fields=id,email`. Any excluded field switches that scope to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Dotted exclusions such as `fields=-owner.avatar` work for relation fields when the relation exists in metadata. Every list/query call must pass either `limit` for a bounded page or `all: true` for a complete list. When a caller needs every matching row, pass `all: true` to `query_table` or `get_all_routes`; the tool sends REST `limit=0` instead of making the model choose an arbitrary page size like 30 or 50.
200
+ Use explicit `fields` in read tools. Include mode is the default, such as `fields=id,email`. Any excluded field switches that scope to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Dotted exclusions such as `fields=-owner.avatar` work for relation fields when the relation exists in metadata. Every list/query call must pass either `limit` for a bounded page or `all: true` for a complete list. When a caller needs every matching row, pass `all: true` to `query_table`, `get_all_routes`, or `get_all_tables`; the tool should not choose an arbitrary page size like 30 or 50.
201
201
 
202
202
  ## Enfyra URL Pattern
203
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.106",
3
+ "version": "0.0.107",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,7 +60,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
60
60
  '',
61
61
  '### Direct HTTP Mapping',
62
62
  '- Route-backed table CRUD is REST: `GET /<table>?...`, `POST /<table>`, `PATCH /<table>/<id>`, `DELETE /<table>/<id>`. There is no `GET /<table>/<id>`; use a filtered list with `limit=1` or `find_one_record`.',
63
- '- REST route lifecycle is controlled by `enfyra_route.isEnabled`: disabled routes are not registered at runtime and return 404. Use `enable_route`/`disable_route` instead of raw route PATCH. REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL requires Bearer auth and table GraphQL enablement.',
63
+ '- REST route lifecycle is controlled by `enfyra_route.isEnabled`: disabled routes are not registered at runtime and return 404. Use `enable_route`/`disable_route` instead of raw route PATCH. REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL table data requires Bearer auth and table GraphQL enablement; anonymous root/schema probes may still return a 200 without exposing table data.',
64
64
  '',
65
65
  'When the user asks for details, fetch only the relevant live context or example category instead of relying on broad memorized rules.',
66
66
  ].join('\n');
@@ -871,8 +871,8 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
871
871
  nextSteps,
872
872
  cleanupHints: latestState.endpoint.routeId
873
873
  ? [
874
- `Preview delete route handler with delete_record({ tableName: "enfyra_route_handler", id: ${JSON.stringify(latestState.endpoint.handlerId)} }) before cleanup.`,
875
- `Preview delete route with delete_record({ tableName: "enfyra_route", id: ${JSON.stringify(latestState.endpoint.routeId)} }) only when no longer needed.`,
874
+ `Use delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, confirm: false }) to preview route-owned handlers, hooks, guards, and permissions before cleanup.`,
875
+ `Then call delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, expectedPath: ${JSON.stringify(latestState.endpoint.path)}, confirm: true }) when the route contract is no longer needed.`,
876
876
  ]
877
877
  : [],
878
878
  };
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { z } from 'zod';
5
5
  import { fetchAPI } from './fetch.js';
6
+ import { jsonContent } from './response-format.js';
6
7
 
7
8
  let schemaQueue = Promise.resolve();
8
9
 
@@ -43,6 +44,19 @@ export function resolveTableFromMetadataByName(metadata, tableName) {
43
44
  .find((table) => table?.name === tableName || table?.alias === tableName) || null;
44
45
  }
45
46
 
47
+ export function resolveTableIdentifierFromMetadata(metadata, tableRef, label = 'table') {
48
+ const resolvedTable = normalizeTablesFromMetadata(metadata)
49
+ .find((table) => (
50
+ String(getId(table)) === String(tableRef) ||
51
+ table?.name === tableRef ||
52
+ table?.alias === tableRef
53
+ ));
54
+ if (!resolvedTable) {
55
+ throw new Error(`${label} "${tableRef}" was not found in metadata. Pass an existing table id, name, or alias from get_all_tables/inspect_table.`);
56
+ }
57
+ return getId(resolvedTable);
58
+ }
59
+
46
60
  /**
47
61
  * Helper: fetch table with full columns and relations.
48
62
  * Dynamic enfyra_table relation fields can be paginated/truncated, so schema
@@ -147,6 +161,14 @@ export function normalizeRelationForTablePatch(relation) {
147
161
  return normalized;
148
162
  }
149
163
 
164
+ function assertNoForbiddenRelationKeys(args) {
165
+ for (const key of FORBIDDEN_RELATION_KEYS) {
166
+ if (Object.prototype.hasOwnProperty.call(args, key)) {
167
+ throw new Error(`create_relation must not include physical column field "${key}". Use sourceTableId/targetTableId and relation propertyName only; Enfyra derives FK and junction columns.`);
168
+ }
169
+ }
170
+ }
171
+
150
172
  export function sanitizeExistingRelationForTablePatch(relation) {
151
173
  const {
152
174
  fkCol,
@@ -307,27 +329,32 @@ export function registerTableTools(server, ENFYRA_API_URL) {
307
329
  });
308
330
  }
309
331
 
310
- async function appendRelationToTable({ sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description }) {
332
+ async function appendRelationToTable(args) {
311
333
  return withSchemaQueue(async () => {
312
- const tableData = await fetchTableWithDetails(ENFYRA_API_URL, sourceTableId);
334
+ assertNoForbiddenRelationKeys(args);
335
+ const { sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = args;
336
+ const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
337
+ const resolvedSourceTableId = resolveTableIdentifierFromMetadata(metadata, sourceTableId, 'sourceTableId');
338
+ const resolvedTargetTableId = resolveTableIdentifierFromMetadata(metadata, targetTableId, 'targetTableId');
339
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, resolvedSourceTableId);
313
340
  if (!tableData) {
314
- return { content: [{ type: 'text', text: `Error: Table with ID ${sourceTableId} not found.` }] };
341
+ return { content: [{ type: 'text', text: `Error: Table ${sourceTableId} not found.` }] };
315
342
  }
316
343
  const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
317
344
  const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
318
- const newRelation = { targetTable: targetTableId, type, propertyName };
345
+ const newRelation = { targetTable: resolvedTargetTableId, type, propertyName };
319
346
  if (inversePropertyName !== undefined) newRelation.inversePropertyName = inversePropertyName || null;
320
347
  if (mappedBy !== undefined) newRelation.mappedBy = mappedBy;
321
348
  if (isNullable !== undefined) newRelation.isNullable = isNullable;
322
349
  if (onDelete !== undefined) newRelation.onDelete = onDelete;
323
350
  if (description !== undefined) newRelation.description = description;
324
- const result = await patchTableAutoConfirm(ENFYRA_API_URL, sourceTableId, { relations: [...existingRelations, newRelation] });
325
- await verifyRelationCascade(ENFYRA_API_URL, sourceTableId, beforeIds, {
351
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, resolvedSourceTableId, { relations: [...existingRelations, newRelation] });
352
+ await verifyRelationCascade(ENFYRA_API_URL, resolvedSourceTableId, beforeIds, {
326
353
  action: 'create',
327
354
  propertyName,
328
355
  });
329
356
  return {
330
- content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${sourceTableId} → ${targetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
357
+ content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${resolvedSourceTableId} → ${resolvedTargetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
331
358
  };
332
359
  });
333
360
  }
@@ -434,8 +461,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
434
461
  };
435
462
 
436
463
  const relationCreateSchema = {
437
- sourceTableId: z.string().describe('Source table ID (the table that owns the FK for many-to-one).'),
438
- targetTableId: z.string().describe('Target table ID.'),
464
+ sourceTableId: z.string().describe('Source table id, exact table name, or alias. For many-to-one, this is the table that owns the relation property.'),
465
+ targetTableId: z.string().describe('Target table id, exact table name, or alias. MCP resolves names/aliases to ids before mutation.'),
439
466
  type: z.enum(['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many']).describe('Relation type.'),
440
467
  propertyName: z.string().describe('Property name on source table (e.g., "customer", "items").'),
441
468
  inversePropertyName: z.string().optional().describe('Property name on target table for bidirectional relation (e.g., "orders"). Omit unless the reverse field is truly needed.'),
@@ -443,6 +470,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
443
470
  isNullable: z.boolean().optional().default(true).describe('Whether the relation is nullable.'),
444
471
  onDelete: z.enum(['CASCADE', 'SET NULL', 'RESTRICT']).optional().default('SET NULL').describe('On delete behavior.'),
445
472
  description: z.string().optional().describe('Relation description.'),
473
+ fkCol: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
474
+ fkColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
475
+ foreignKeyColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
476
+ sourceColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
477
+ targetColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
478
+ junctionSourceColumn: z.never().optional().describe('Forbidden. Use relation property names only; Enfyra derives junction columns.'),
479
+ junctionTargetColumn: z.never().optional().describe('Forbidden. Use relation property names only; Enfyra derives junction columns.'),
446
480
  };
447
481
 
448
482
  const columnDeleteSchema = {
@@ -461,13 +495,45 @@ export function registerTableTools(server, ENFYRA_API_URL) {
461
495
 
462
496
  server.tool(
463
497
  'get_all_tables',
464
- 'Get all table definitions in the system',
465
- {},
466
- async () => {
467
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_table?limit=500');
468
- return {
469
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
470
- };
498
+ 'List table definitions from metadata. Every call must pass either limit or all=true. Use search to narrow by table name or alias.',
499
+ {
500
+ limit: z.number().int().positive().optional().describe('Maximum tables returned after search. Required unless all=true.'),
501
+ all: z.boolean().optional().describe('Return all matched tables. Use this when a complete table list is required.'),
502
+ search: z.string().optional().describe('Optional table name, alias, or description substring filter.'),
503
+ },
504
+ async ({ limit, all, search }) => {
505
+ if (!all && limit === undefined) {
506
+ throw new Error('get_all_tables requires either limit or all=true. Do not invent arbitrary limits for complete table lists; use all=true.');
507
+ }
508
+ const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
509
+ const needle = search?.trim().toLowerCase();
510
+ const tables = normalizeTablesFromMetadata(metadata)
511
+ .map((table) => ({
512
+ id: getId(table),
513
+ name: table.name ?? null,
514
+ alias: table.alias ?? null,
515
+ description: table.description ?? null,
516
+ isSingleRecord: table.isSingleRecord ?? null,
517
+ columnCount: Array.isArray(table.columns) ? table.columns.length : null,
518
+ relationCount: Array.isArray(table.relations) ? table.relations.length : null,
519
+ routeBacked: Boolean(table.route || table.routeId || table.path),
520
+ }))
521
+ .filter((table) => {
522
+ if (!needle) return true;
523
+ return [table.name, table.alias, table.description]
524
+ .some((value) => String(value || '').toLowerCase().includes(needle));
525
+ });
526
+ const returnedTables = all ? tables : tables.slice(0, limit);
527
+ return jsonContent({
528
+ action: 'get_all_tables',
529
+ totalTableCount: normalizeTablesFromMetadata(metadata).length,
530
+ matchedTableCount: tables.length,
531
+ returnedTableCount: returnedTables.length,
532
+ all: Boolean(all),
533
+ search: search || null,
534
+ tables: returnedTables,
535
+ detailHint: 'Use inspect_table with a table id/name for columns, relations, indexes, routes, permissions, and GraphQL state.',
536
+ });
471
537
  }
472
538
  );
473
539
 
@@ -558,7 +624,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
558
624
  alias: z.string().optional().describe('New table alias.'),
559
625
  description: z.string().optional().describe('New description.'),
560
626
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
561
- graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing enfyra_graphql.isEnabled. GraphQL still requires Bearer auth.'),
627
+ graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing enfyra_graphql.isEnabled. GraphQL table data still requires Bearer auth; anonymous root or schema probes may return 200.'),
562
628
  indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on enfyra_table.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
563
629
  uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on enfyra_table.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
564
630
  },
@@ -740,6 +806,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
740
806
  'create_relation',
741
807
  [
742
808
  'Create a relation between two tables (many-to-one, one-to-many, one-to-one, many-to-many).',
809
+ 'sourceTableId and targetTableId may be table ids, exact table names, or aliases; MCP resolves them from metadata before mutation.',
743
810
  'For many-to-one: a physical FK column is created on the source table. For one-to-many: the FK is on the target (inverse relation). This physical FK is derived by Enfyra and hidden from app schema/forms.',
744
811
  'Never ask the user for physical FK column names and never send fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. The public API uses relation propertyName only.',
745
812
  'Run sequentially — DB migration locks per operation.',
@@ -754,6 +821,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
754
821
  'add_relation',
755
822
  [
756
823
  'Alias for create_relation. Add a relation through the canonical enfyra_table cascade.',
824
+ 'sourceTableId and targetTableId may be table ids, exact table names, or aliases.',
757
825
  'Use relation propertyName only; never provide physical FK or junction column names.',
758
826
  'Run schema changes sequentially — migration locks DB per operation.',
759
827
  ].join(' '),
@@ -64,7 +64,7 @@ const CAPABILITY_AREAS = [
64
64
  {
65
65
  area: 'GraphQL',
66
66
  tables: ['enfyra_graphql'],
67
- workflow: 'Enable per table through enfyra_graphql or update_table graphqlEnabled. GraphQL requires Bearer auth.',
67
+ workflow: 'Enable per table through enfyra_graphql or update_table graphqlEnabled. GraphQL table data requires Bearer auth; anonymous root or schema probes may return 200 without exposing table data.',
68
68
  },
69
69
  {
70
70
  area: 'Files and storage',
@@ -746,7 +746,7 @@ server.tool(
746
746
  endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
747
747
  schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
748
748
  enablement: 'A table appears in GraphQL when enfyra_graphql has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
749
- auth: 'GraphQL currently requires Authorization: Bearer <accessToken>; REST publicMethods does not make GraphQL anonymous.',
749
+ auth: 'GraphQL table data requires Authorization: Bearer <accessToken>; REST publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
750
750
  management: routeTables.has('enfyra_graphql')
751
751
  ? 'Use update_table graphqlEnabled or create/update records on enfyra_graphql, then reload_graphql if needed.'
752
752
  : 'Use update_table graphqlEnabled, then reload_graphql if needed.',
@@ -914,7 +914,7 @@ server.tool(
914
914
  : 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
915
915
  relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
916
916
  relationCascadeFkContract: 'When creating relations through create_table/create_relation/enfyra_table PATCH, never provide fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. These are physical implementation details derived by Enfyra and hidden from app schema/forms.',
917
- graphql: 'GraphQL query args also accept filter/sort/page/limit, but GraphQL requires Bearer auth and table enablement via enfyra_graphql.',
917
+ graphql: 'GraphQL query args also accept filter/sort/page/limit. Table data requires Bearer auth and table enablement via enfyra_graphql; anonymous root/schema probes may still return 200.',
918
918
  },
919
919
  table: tableName
920
920
  ? {
@@ -1052,7 +1052,7 @@ server.tool(
1052
1052
  graphqlResolver: {
1053
1053
  runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
1054
1054
  data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
1055
- caveat: 'REST publicMethods do not make GraphQL anonymous.',
1055
+ caveat: 'REST publicMethods do not make GraphQL table data anonymous.',
1056
1056
  },
1057
1057
  extensionVueSfc: {
1058
1058
  runs: 'Frontend extension code, not server sandbox.',
@@ -1095,7 +1095,7 @@ server.tool(
1095
1095
  'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
1096
1096
  'If path might differ from table name, use get_all_routes before asserting a URL.',
1097
1097
  'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
1098
- 'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_table graphqlEnabled and send Bearer auth.',
1098
+ 'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_table graphqlEnabled and send Bearer auth for table data queries. Anonymous root/schema probes may still return 200.',
1099
1099
  ].join(' '),
1100
1100
  {},
1101
1101
  async () => {
@@ -1113,7 +1113,7 @@ server.tool(
1113
1113
  },
1114
1114
  auth: {
1115
1115
  publicMethods: 'If the HTTP method is public for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
1116
- graphql: 'GraphQL currently requires Bearer auth; route publicMethods do not make GraphQL anonymous.',
1116
+ graphql: 'GraphQL table data requires Bearer auth; route publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
1117
1117
  mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
1118
1118
  },
1119
1119
  pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',