@enfyra/mcp-server 0.0.106 → 0.0.108

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
 
@@ -231,7 +231,7 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
231
231
 
232
232
  The MCP server exposes tools for metadata discovery, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, and `get_enfyra_api_context`.
233
233
 
234
- Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods`, `set_public_route_methods`, and `private_route_methods` for that access boundary.
234
+ Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods` and `private_route_methods` for that access boundary.
235
235
 
236
236
  For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by route path plus role/user. For production script edits, use `trace_metadata_usage`, `get_script_source`, and `patch_script_source` so changes are targeted, hash-checked, and validated.
237
237
 
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.108",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
36
36
  '',
37
37
  '### Core Contracts',
38
38
  '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Large arrays of objects may be encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }` only when that is smaller than raw JSON; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings and includes whether compression was applied; use it only when the user asks about savings.',
39
- '- `query_table` and `get_all_routes` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
39
+ '- `query_table`, `get_all_routes`, and `get_all_tables` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
40
40
  '- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
41
41
  '- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
42
42
  '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
@@ -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');
@@ -72,7 +72,7 @@ export function rejectUnsafeRelationDefinitionPayload(tableName, payload) {
72
72
  if (forbidden.length > 0) {
73
73
  throw new Error(
74
74
  `Do not send physical FK/junction fields to enfyra_relation: ${forbidden.join(', ')}. ` +
75
- 'Use create_relation/add_relation with targetTable/type/propertyName; Enfyra derives physical columns.'
75
+ 'Use create_relation with targetTable/type/propertyName; Enfyra derives physical columns.'
76
76
  );
77
77
  }
78
78
  }
@@ -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
  };
@@ -1047,22 +1047,6 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1047
1047
  })),
1048
1048
  );
1049
1049
 
1050
- server.tool(
1051
- 'set_public_route_methods',
1052
- 'Business operation: replace a route publicMethods list exactly.',
1053
- {
1054
- path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1055
- routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1056
- methods: z.array(z.string()).describe('Exact HTTP method names that should be public. Use an empty array to make all methods private.'),
1057
- },
1058
- async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
1059
- path,
1060
- routeId,
1061
- methods,
1062
- mode: 'replace',
1063
- })),
1064
- );
1065
-
1066
1050
  server.tool(
1067
1051
  'private_route_methods',
1068
1052
  'Business operation: make specific public route methods private again.',
@@ -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
  }
@@ -354,7 +381,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
354
381
  targetColumn: target,
355
382
  preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
356
383
  destructive: true,
357
- next: 'Call delete_column/remove_column again with confirm=true to drop the physical column and metadata.',
384
+ next: 'Call delete_column again with confirm=true to drop the physical column and metadata.',
358
385
  }, null, 2) }],
359
386
  };
360
387
  }
@@ -396,7 +423,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
396
423
  targetRelation: target,
397
424
  preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
398
425
  destructive: true,
399
- next: 'Call delete_relation/remove_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
426
+ next: 'Call delete_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
400
427
  }, null, 2) }],
401
428
  };
402
429
  }
@@ -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
  },
@@ -633,18 +699,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
633
699
  appendColumnToTable
634
700
  );
635
701
 
636
- server.tool(
637
- 'add_column',
638
- [
639
- 'Alias for create_column. Add a column to an existing table through the canonical enfyra_table cascade.',
640
- 'Use this for schema additions, including hidden secret fields with isPublished=false.',
641
- 'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
642
- 'Run schema changes sequentially — migration locks DB per operation.',
643
- ].join(' '),
644
- columnCreateSchema,
645
- appendColumnToTable
646
- );
647
-
648
702
  // ─── UPDATE COLUMN ───
649
703
 
650
704
  server.tool(
@@ -722,24 +776,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
722
776
  removeColumnFromTable
723
777
  );
724
778
 
725
- server.tool(
726
- 'remove_column',
727
- [
728
- 'Alias for delete_column. Remove a column through the canonical enfyra_table cascade.',
729
- 'This drops the physical column. Confirm destructive schema changes before calling.',
730
- 'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
731
- 'Run schema changes sequentially — migration locks DB per operation.',
732
- ].join(' '),
733
- columnDeleteSchema,
734
- removeColumnFromTable
735
- );
736
-
737
779
  // ─── CREATE RELATION ───
738
780
 
739
781
  server.tool(
740
782
  'create_relation',
741
783
  [
742
784
  'Create a relation between two tables (many-to-one, one-to-many, one-to-one, many-to-many).',
785
+ 'sourceTableId and targetTableId may be table ids, exact table names, or aliases; MCP resolves them from metadata before mutation.',
743
786
  '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
787
  '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
788
  'Run sequentially — DB migration locks per operation.',
@@ -750,17 +793,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
750
793
  appendRelationToTable
751
794
  );
752
795
 
753
- server.tool(
754
- 'add_relation',
755
- [
756
- 'Alias for create_relation. Add a relation through the canonical enfyra_table cascade.',
757
- 'Use relation propertyName only; never provide physical FK or junction column names.',
758
- 'Run schema changes sequentially — migration locks DB per operation.',
759
- ].join(' '),
760
- relationCreateSchema,
761
- appendRelationToTable
762
- );
763
-
764
796
  // ─── DELETE RELATION ───
765
797
 
766
798
  server.tool(
@@ -776,13 +808,4 @@ export function registerTableTools(server, ENFYRA_API_URL) {
776
808
  removeRelationFromTable
777
809
  );
778
810
 
779
- server.tool(
780
- 'remove_relation',
781
- [
782
- 'Alias for delete_relation. Remove a relation through the canonical enfyra_table cascade.',
783
- 'This can drop FK columns or junction tables. Confirm destructive schema changes before calling.',
784
- ].join(' '),
785
- relationDeleteSchema,
786
- removeRelationFromTable
787
- );
788
811
  }
@@ -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.',
@@ -2468,137 +2468,6 @@ server.tool(
2468
2468
  },
2469
2469
  );
2470
2470
 
2471
- server.tool(
2472
- 'create_column_rule',
2473
- [
2474
- 'Create a REST body validation rule for a table column.',
2475
- 'Use inspect_table first to confirm validateBody, column type, and existing rules. Rule value is JSON; common shape is {"v": ...}.',
2476
- ].join(' '),
2477
- {
2478
- tableName: z.string().describe('Table name or alias'),
2479
- columnName: z.string().describe('Column name'),
2480
- ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type'),
2481
- value: z.string().optional().describe('Rule payload JSON, e.g. {"v":10} or {"v":"email"}'),
2482
- message: z.string().optional().describe('Custom validation error message'),
2483
- description: z.string().optional().describe('Admin note'),
2484
- isEnabled: z.boolean().optional().default(true).describe('Enable the rule immediately'),
2485
- },
2486
- async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
2487
- const { tables } = await getMetadataTables();
2488
- const table = resolveTableOrThrow(tables, tableName);
2489
- const column = resolveFieldOrThrow(table, columnName, 'column');
2490
- const body = {
2491
- ruleType,
2492
- value: parseJsonArg(value, null),
2493
- message,
2494
- description,
2495
- isEnabled,
2496
- column: { id: getId(column) },
2497
- };
2498
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_column_rule', {
2499
- method: 'POST',
2500
- body: JSON.stringify(body),
2501
- });
2502
- return { content: [{ type: 'text', text: `Column rule created for ${table.name}.${column.name}.\n${JSON.stringify(result, null, 2)}` }] };
2503
- },
2504
- );
2505
-
2506
- server.tool(
2507
- 'create_field_permission',
2508
- [
2509
- 'Create a field permission for one column or relation.',
2510
- 'Exactly one of columnName or relationName is required. Scope requires roleId or allowedUserIds. Conditions use the field permission condition DSL, not the full query DSL.',
2511
- ].join(' '),
2512
- {
2513
- tableName: z.string().describe('Table name or alias'),
2514
- columnName: z.string().optional().describe('Column name to protect'),
2515
- relationName: z.string().optional().describe('Relation propertyName to protect'),
2516
- action: z.enum(['read', 'create', 'update']).default('read').describe('Action this permission applies to'),
2517
- effect: z.enum(['allow', 'deny']).default('allow').describe('Allow or deny this field action'),
2518
- roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2519
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
2520
- condition: z.string().optional().describe('Optional condition JSON using field permission condition DSL'),
2521
- description: z.string().optional().describe('Admin note'),
2522
- isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
2523
- },
2524
- async ({ tableName, columnName, relationName, action, effect, roleId, allowedUserIds, condition, description, isEnabled }) => {
2525
- if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName');
2526
- if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
2527
- throw new Error('Provide roleId or allowedUserIds');
2528
- }
2529
- const { tables } = await getMetadataTables();
2530
- const table = resolveTableOrThrow(tables, tableName);
2531
- const body = {
2532
- isEnabled,
2533
- description,
2534
- action,
2535
- effect,
2536
- condition: parseJsonArg(condition, null),
2537
- ...(roleId ? { role: { id: roleId } } : {}),
2538
- ...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
2539
- };
2540
- if (columnName) {
2541
- body.column = { id: getId(resolveFieldOrThrow(table, columnName, 'column')) };
2542
- } else {
2543
- body.relation = { id: getId(resolveFieldOrThrow(table, relationName, 'relation')) };
2544
- }
2545
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_field_permission', {
2546
- method: 'POST',
2547
- body: JSON.stringify(body),
2548
- });
2549
- return { content: [{ type: 'text', text: `Field permission created on ${table.name}.${columnName || relationName}.\n${JSON.stringify(result, null, 2)}` }] };
2550
- },
2551
- );
2552
-
2553
- server.tool(
2554
- 'create_route_permission',
2555
- [
2556
- 'Create route access permission for a route and REST methods.',
2557
- 'Use this when a non-root role/user should access an authenticated route. publicMethods are for public access; route permissions are for authenticated role/user access.',
2558
- ].join(' '),
2559
- {
2560
- path: z.string().optional().describe('Route path, e.g. /enfyra_user'),
2561
- routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
2562
- methods: z.array(z.string()).describe('REST method names this permission allows. Each value must exist in enfyra_method.name.'),
2563
- roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2564
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
2565
- description: z.string().optional().describe('Admin note'),
2566
- isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
2567
- },
2568
- async ({ path, routeId, methods, roleId, allowedUserIds, description, isEnabled }) => {
2569
- if (!path && !routeId) throw new Error('Provide path or routeId');
2570
- if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
2571
- throw new Error('Provide roleId or allowedUserIds');
2572
- }
2573
- const routes = await fetchAll('/enfyra_route?limit=1000');
2574
- const route = routes.find((item) => (
2575
- routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2576
- ));
2577
- if (!route) throw new Error(`Route not found: ${routeId || path}`);
2578
- const methodMap = await getMethodMap();
2579
- const body = {
2580
- isEnabled,
2581
- description,
2582
- route: { id: getId(route) },
2583
- methods: resolveMethodIds(methodMap, methods),
2584
- ...(roleId ? { role: { id: roleId } } : {}),
2585
- ...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
2586
- };
2587
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_permission', {
2588
- method: 'POST',
2589
- body: JSON.stringify(body),
2590
- });
2591
- const routeReload = await reloadRoutesResult();
2592
- return { content: [{ type: 'text', text: JSON.stringify({
2593
- action: 'created',
2594
- kind: 'route_permission',
2595
- route: route.path,
2596
- routeReload,
2597
- result,
2598
- }, null, 2) }] };
2599
- },
2600
- );
2601
-
2602
2471
  server.tool(
2603
2472
  'audit_route_access',
2604
2473
  [
@@ -2772,74 +2641,6 @@ server.tool(
2772
2641
  },
2773
2642
  );
2774
2643
 
2775
- server.tool(
2776
- 'create_guard',
2777
- [
2778
- 'Create a metadata guard with optional rules for REST request gating.',
2779
- 'Root guards attach to one route by path/routeId or globally with isGlobal. pre_auth runs before JWT and only has IP/route context; post_auth runs after auth and can use user id.',
2780
- 'Rule types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist. Rate limits use {"maxRequests":number,"perSeconds":number}; IP lists use {"ips":["127.0.0.1","10.0.0.0/24"]}.',
2781
- 'Do not use rate_limit_by_user or userIds on pre_auth guards. Create risky global/IP whitelist guards disabled first, then inspect and test before enabling.',
2782
- ].join(' '),
2783
- {
2784
- name: z.string().describe('Guard name'),
2785
- position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard. pre_auth has only IP/route context; post_auth also has authenticated user id.'),
2786
- routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id'),
2787
- path: z.string().optional().describe('Optional route path'),
2788
- methods: z.array(z.string()).optional().describe('Method names this guard applies to. Empty means all configured behavior for route/global.'),
2789
- combinator: z.enum(['and', 'or']).default('and').describe('How child guards/rules combine'),
2790
- priority: z.number().optional().default(0).describe('Lower runs first'),
2791
- isGlobal: z.boolean().optional().default(false).describe('Apply globally instead of one route'),
2792
- isEnabled: z.boolean().optional().default(false).describe('Enable immediately. Default false to avoid accidental lockout.'),
2793
- description: z.string().optional().describe('Admin note'),
2794
- rules: z.string().optional().describe('Optional rules JSON array: [{type, config, priority?, isEnabled?, description?, userIds?}]. Supported types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist.'),
2795
- },
2796
- async ({ name, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules }) => {
2797
- let route = null;
2798
- if (!isGlobal && (routeId || path)) {
2799
- const routes = await fetchAll('/enfyra_route?limit=1000');
2800
- route = routes.find((item) => (
2801
- routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2802
- ));
2803
- if (!route) throw new Error(`Route not found: ${routeId || path}`);
2804
- }
2805
- const methodMap = await getMethodMap();
2806
- const guardBody = {
2807
- name,
2808
- position,
2809
- combinator,
2810
- priority,
2811
- isGlobal,
2812
- isEnabled,
2813
- description,
2814
- ...(route ? { route: { id: getId(route) } } : {}),
2815
- ...(methods?.length ? { methods: resolveMethodIds(methodMap, methods) } : {}),
2816
- };
2817
- const guard = await fetchAPI(ENFYRA_API_URL, '/enfyra_guard', {
2818
- method: 'POST',
2819
- body: JSON.stringify(guardBody),
2820
- });
2821
- const ruleInputs = parseJsonArg(rules, []);
2822
- const createdRules = [];
2823
- for (const rule of ruleInputs) {
2824
- const ruleBody = {
2825
- type: rule.type,
2826
- config: rule.config,
2827
- priority: rule.priority ?? 0,
2828
- isEnabled: rule.isEnabled ?? true,
2829
- description: rule.description,
2830
- guard: { id: resultRecordId(guard) },
2831
- ...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
2832
- };
2833
- createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
2834
- method: 'POST',
2835
- body: JSON.stringify(ruleBody),
2836
- }));
2837
- }
2838
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/guards', { method: 'POST' }).catch(() => {});
2839
- return { content: [{ type: 'text', text: `Guard created. Guard cache reloaded.\n${JSON.stringify({ guard, rules: createdRules }, null, 2)}` }] };
2840
- },
2841
- );
2842
-
2843
2644
  // Register table tools
2844
2645
  registerTableTools(server, ENFYRA_API_URL);
2845
2646
  registerPlatformOperationTools(server, ENFYRA_API_URL);