@enfyra/mcp-server 0.0.70 → 0.0.72

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
@@ -186,6 +186,8 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
186
186
 
187
187
  Schema and script tools include safety guards for LLM callers: generic record mutations validate request fields against live metadata, script-backed records must validate `sourceCode` before save through `/admin/script/validate` and fail closed if validation is unavailable, relation metadata rejects physical FK/junction inputs, custom routes reject `mainTableId` unless the path is the canonical table route, schema tools serialize table/column/relation changes, and destructive deletes require `confirm=true` after returning a preview.
188
188
 
189
+ Read tools use Enfyra's `fields` parameter directly. Passing explicit includes such as `fields=id,email` returns only those fields, while any `-field` token switches that scope to exclude mode. For example, `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Nested exclusions work with dotted fields and `deep`, such as `fields=-owner.avatar` or `deep.owner.fields=-avatar`.
190
+
189
191
  Quick checklist for a new LLM using Enfyra MCP: discover the live system first, inspect the specific table/route, load the matching example category, mutate with explicit fields and relation property names, validate or test scripts/routes before relying on them, re-read the saved row when mutation output is summarized, and preview destructive operations before confirming.
190
192
 
191
193
  Use `update_script_source` when updating existing long script-backed records such as `flow_step_definition`, `route_handler_definition`, hook tables, websocket scripts, GraphQL scripts, or bootstrap scripts. It accepts raw `sourceCode` directly, validates the source, and saves `sourceCode`/`scriptLanguage` without requiring the caller to manually JSON-escape the full script. Use generic `update_record` for small record patches or patches that include non-script metadata fields.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.70",
3
+ "version": "0.0.72",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -218,6 +218,7 @@ window.location.href = url.toString()`,
218
218
  })`,
219
219
  notes: [
220
220
  'Use user_definition as the user table.',
221
+ 'Use table ids for targetTable when already known; MCP can also resolve exact table names such as "user_definition" before schema mutation.',
221
222
  'Do not add inverse relations on user_definition unless the user explicitly asks.',
222
223
  'createdAt, updatedAt, and custom date/datetime/timestamp fields already get auto-generated single-field indexes; add only compound indexes needed by hot filters.',
223
224
  'Do not provide physical FK column names; Enfyra derives them.',
@@ -418,6 +419,34 @@ GET /enfyra/post?filter={"<primaryKeyFromMetadata>":{"_eq":123}}&limit=1`,
418
419
  'Do not add deep when fields alone can express the relation data you need.',
419
420
  ],
420
421
  },
422
+ {
423
+ name: 'Exclude large generated fields',
424
+ code: `query_table({
425
+ tableName: "route_handler_definition",
426
+ fields: ["-compiledCode"],
427
+ limit: 20
428
+ })
429
+
430
+ query_table({
431
+ tableName: "post",
432
+ fields: ["id", "-author.avatar"],
433
+ deep: JSON.stringify({
434
+ comments: {
435
+ fields: "-compiledCode,-author.avatar",
436
+ limit: 10,
437
+ deep: {
438
+ author: { fields: "-avatar" }
439
+ }
440
+ }
441
+ })
442
+ })`,
443
+ notes: [
444
+ 'Use fields=-compiledCode when reading script-backed records; sourceCode is the editable contract and compiledCode is generated by the server.',
445
+ 'Any -field token switches that fields scope to exclude mode, so fields=id,-compiledCode returns all readable fields except compiledCode.',
446
+ 'Dotted exclusions and deep relation fields use the same exclude-mode rule.',
447
+ 'Excluded fields and relations must exist in metadata; typos should fail instead of silently returning large or sensitive fields.',
448
+ ],
449
+ },
421
450
  {
422
451
  name: 'Deep relation query options',
423
452
  code: `query_table({
@@ -1101,19 +1130,28 @@ create_extension({
1101
1130
 
1102
1131
  <script setup>
1103
1132
  const unread = ref(0)
1133
+ const expanded = ref(false)
1134
+
1135
+ const notificationDescription = computed(() => {
1136
+ if (unread.value > 0) return unread.value === 1 ? '1 unread' : unread.value + ' unread'
1137
+ return 'All caught up'
1138
+ })
1139
+ const notificationBadge = computed(() => unread.value > 0 ? (unread.value > 99 ? '99+' : unread.value) : null)
1140
+ const notificationIcon = computed(() => unread.value > 0 ? 'lucide:bell-ring' : 'lucide:bell')
1104
1141
 
1105
- const NotificationBell = defineComponent({
1106
- name: 'NotificationBell',
1142
+ const NotificationList = defineComponent({
1143
+ name: 'NotificationList',
1107
1144
  setup() {
1108
- const openNotifications = () => navigateTo('/notifications')
1109
- return () => h(UButton, {
1110
- type: 'button',
1111
- icon: unread.value > 0 ? 'lucide:bell-ring' : 'lucide:bell',
1112
- color: unread.value > 0 ? 'primary' : 'neutral',
1113
- variant: unread.value > 0 ? 'solid' : 'ghost',
1114
- label: unread.value > 0 ? String(unread.value) : undefined,
1115
- onClick: openNotifications,
1116
- })
1145
+ return () => h('div', { class: 'p-2 text-sm' }, [
1146
+ h('button', {
1147
+ type: 'button',
1148
+ class: 'flex w-full items-center justify-between rounded px-2 py-2 text-left hover:bg-[var(--surface-muted)]',
1149
+ onClick: () => navigateTo('/notifications'),
1150
+ }, [
1151
+ h('span', 'Open notification center'),
1152
+ h(resolveComponent('UIcon'), { name: 'lucide:arrow-right', class: 'h-4 w-4' }),
1153
+ ]),
1154
+ ])
1117
1155
  },
1118
1156
  })
1119
1157
 
@@ -1121,7 +1159,16 @@ const { register } = useAccountPanelRegistry()
1121
1159
  register({
1122
1160
  id: 'notifications',
1123
1161
  order: 20,
1124
- component: NotificationBell,
1162
+ label: 'Notifications',
1163
+ icon: notificationIcon,
1164
+ description: notificationDescription,
1165
+ badge: notificationBadge,
1166
+ badgeColor: 'error',
1167
+ expanded,
1168
+ onToggle: () => {
1169
+ expanded.value = !expanded.value
1170
+ },
1171
+ contentComponent: NotificationList,
1125
1172
  })
1126
1173
 
1127
1174
  const { adminSocket } = useAdminSocket()
@@ -1146,38 +1193,27 @@ create_extension({
1146
1193
  'Global extensions are mounted invisibly by eApp during layout init; do not create a menu and do not embed them with Widget.',
1147
1194
  'Use them for shell-level registrations, realtime listeners, notification counters, account panel rows, and background refresh bridges.',
1148
1195
  'Keep the global extension template empty or hidden; visible UI should be registered into an existing shell registry or component slot.',
1149
- 'For account-panel UI, keep the control compact: icon left, label/microcopy center, count/status badge right, one click target, and no floating card around it.',
1196
+ 'For account-panel UI, register data-driven row fields so eApp owns icon size, row spacing, badge placement, hover state, and expanded chrome.',
1197
+ 'Use contentComponent only for expanded inner content; use raw component only as an escape hatch when the row cannot fit the shell contract.',
1150
1198
  'Destructure registry functions and register stable ids so reloads replace the same shell item predictably.',
1151
1199
  'Remove socket or DOM listeners in onUnmounted; eApp unmounts old global components when extension cache reloads or the extension is disabled.',
1152
1200
  ],
1153
1201
  },
1154
1202
  {
1155
- name: 'Design a global account-panel item with proper shell UI',
1203
+ name: 'Register a data-driven account-panel item',
1156
1204
  code: `<script setup>
1157
1205
  const unread = ref(3)
1206
+ const expanded = ref(false)
1207
+
1208
+ const label = 'Notifications'
1209
+ const icon = computed(() => unread.value > 0 ? 'lucide:bell-ring' : 'lucide:bell')
1210
+ const badge = computed(() => unread.value > 0 ? String(unread.value) : null)
1211
+ const description = computed(() => unread.value > 0 ? 'Needs review' : 'All caught up')
1158
1212
 
1159
- const NotificationPanelItem = defineComponent({
1160
- name: 'NotificationPanelItem',
1213
+ const NotificationPanelContent = defineComponent({
1214
+ name: 'NotificationPanelContent',
1161
1215
  setup() {
1162
- const openNotifications = () => navigateTo('/notifications')
1163
- return () => h('button', {
1164
- type: 'button',
1165
- class: 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition hover:bg-muted',
1166
- onClick: openNotifications,
1167
- }, [
1168
- h('span', {
1169
- class: 'flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary',
1170
- }, [
1171
- h(UIcon, { name: unread.value > 0 ? 'lucide:bell-ring' : 'lucide:bell', class: 'h-5 w-5' }),
1172
- ]),
1173
- h('span', { class: 'min-w-0 flex-1' }, [
1174
- h('span', { class: 'block truncate text-sm font-medium text-highlighted' }, 'Notifications'),
1175
- h('span', { class: 'mt-0.5 block truncate text-xs text-muted' }, unread.value > 0 ? 'Needs review' : 'All caught up'),
1176
- ]),
1177
- unread.value > 0
1178
- ? h(UBadge, { color: 'primary', variant: 'soft', size: 'sm' }, () => String(unread.value))
1179
- : h(UIcon, { name: 'lucide:chevron-right', class: 'h-4 w-4 text-muted' }),
1180
- ])
1216
+ return () => h('div', { class: 'px-2 py-1 text-xs text-[var(--text-tertiary)]' }, 'Recent unread notifications can render here.')
1181
1217
  },
1182
1218
  })
1183
1219
 
@@ -1185,14 +1221,23 @@ const { register } = useAccountPanelRegistry()
1185
1221
  register({
1186
1222
  id: 'notifications',
1187
1223
  order: 20,
1188
- component: NotificationPanelItem,
1224
+ label,
1225
+ icon,
1226
+ description,
1227
+ badge,
1228
+ badgeColor: 'error',
1229
+ expanded,
1230
+ onToggle: () => {
1231
+ expanded.value = !expanded.value
1232
+ },
1233
+ contentComponent: NotificationPanelContent,
1189
1234
  })
1190
1235
  </script>`,
1191
1236
  notes: [
1192
- 'Use this shape for shell/account-panel items: one row, aligned icon, readable label, short secondary text, and a compact trailing badge or chevron.',
1193
- 'Do not use page-scale cards, hero headings, large whitespace, or a separate modal just to expose a shell entry.',
1194
- 'Use `rounded-lg` or smaller radii, controlled padding, and the shell tokens/classes (`bg-muted`, `text-muted`, `text-highlighted`) so the item matches eApp.',
1195
- 'Keep the entire row as one button with `type="button"`; avoid nested buttons inside account-panel rows.',
1237
+ 'Prefer this contract for shell/account-panel items: data fields for the row, optional contentComponent for the expanded body.',
1238
+ 'Do not draw a custom full row with page-scale cards, hero headings, large whitespace, or nested buttons unless the shell contract cannot express the UI.',
1239
+ 'Let eApp handle the row button, icon container, label, microcopy, badge, chevron, hover state, spacing, and expanded wrapper.',
1240
+ 'Keep contentComponent compact; it is rendered inside account-panel chrome and should not create another large card around itself.',
1196
1241
  'Register the component from a `type="global"` extension, not from a page extension, when it must appear everywhere.',
1197
1242
  ],
1198
1243
  },
@@ -86,7 +86,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
86
86
  '- MCP **`create_table` and `update_table` support `indexes` and `uniques`** as JSON arrays of logical field groups. Use compound indexes for hot filters and unread/read state, e.g. `indexes: [["member","isRead","conversation"],["conversation","member","isRead"]]` and `uniques: [["message","member"]]`. Relation property names are allowed; Enfyra resolves them to physical FK columns for SQL and Mongo.',
87
87
  '- Enfyra auto-generates single-field indexes for `createdAt`, `updatedAt`, and scalar time columns with type `date`, `datetime`, or `timestamp`. SQL appends `id` as the stable tie-breaker, and Mongo appends `_id`. Do not add duplicate single-field indexes for these time fields; add explicit compound indexes only when a hot query combines the time field with other filters such as status, owner, tenant, or relation fields.',
88
88
  '- MCP **`create_table` does not accept `alias`**. Do not invent or send alias during table creation; default route/schema behavior is based on `name`. Use `update_table` later only when alias truly needs to change.',
89
- '- In `create_table.relations`, each relation uses `targetTable` (table id or `{id}`), `type`, `propertyName`, optional `inversePropertyName` or `mappedBy`, `isNullable`, `onDelete`, and `description`. The target table must already exist.',
89
+ '- In `create_table.relations`, each relation uses `targetTable`, `type`, `propertyName`, optional `inversePropertyName` or `mappedBy`, `isNullable`, `onDelete`, and `description`. Prefer table id or `{id}` for `targetTable`; MCP also accepts an exact table name such as `"user_definition"` and resolves it to the live table id before mutation. The target table must already exist.',
90
90
  '- **Use `user_definition` as the only user table.** Do not create app-specific user/profile mapping tables such as `chat_profile`, `app_user`, `customer_user`, or tables that only mirror/link Enfyra users. If an app needs extra user fields or user relations, add columns/relations directly on `user_definition`.',
91
91
  '- When modeling features that involve users, relate domain tables directly to `user_definition` through real Enfyra relations. Examples: `chat_conversation_member.member` → `user_definition`, `chat_message.sender` → `user_definition`, `order.customer` → `user_definition`. Do not create duplicate scalar columns like `userId` or separate profile records just to point back to a user.',
92
92
  '- **Do not create reverse relations on `user_definition` by default.** For domain records that point to users, create only the owning relation on the domain table, e.g. `chat_message.sender -> user_definition`, and omit `inversePropertyName` unless the user explicitly asks for a reverse user field. Reverse fields like `user_definition.chatMessages`, `user_definition.orders`, or `user_definition.memberships` bloat user metadata and make `fields=*` user queries heavy.',
@@ -244,6 +244,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
244
244
  '- Field permission condition DSL is narrower and does not support `_contains`, `_starts_with`, `_ends_with`, or `_between`.',
245
245
  '- Root `sort` accepts local fields such as `-createdAt` plus direct list-relation aggregate helpers: `_count(relationName)`, `_max(relationName.fieldName)`, and `_min(relationName.fieldName)`. Use `-_max(messages.createdAt)` to order parent rows by the latest child row. The relation must be direct `one-to-many` or `many-to-many`, and the aggregate field must be a non-encrypted scalar field on the related table.',
246
246
  '- Raw dotted to-many sort such as `messages.createdAt` is invalid for parent ordering. `deep: { messages: { sort: "-createdAt" } }` sorts the loaded child rows inside each parent only; it does not sort the parent list.',
247
+ '- Field selection has two modes per query scope. Include mode is the default: `fields=id,email,owner.name` returns only those fields. If any token starts with `-`, that scope switches to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode` because positive tokens are ignored in exclude mode. Dotted exclusions such as `fields=-owner.avatar` and nested deep fields such as `deep: { owner: { fields: "-avatar" } }` also switch that scope to exclude mode. Unknown excluded fields/relations are request errors, so inspect metadata before excluding guessed names.',
247
248
  '- Deep shape: `{ relationName: { fields?, filter?, sort?, limit?, page?, deep? } }`. Relation keys are relation `propertyName`, not physical FK columns.',
248
249
  '- Use dotted relation fields such as `owner.email` or `lastMessage.text` when the caller only needs basic related record fields. Use `deep` when relation loading needs query options such as `filter`, `sort`, `limit`, `page`, or nested `deep`. Do not use `deep` for simple relation-id filters, one-row lookup, counts, or large child collections that should be loaded separately with pagination.',
249
250
  '- Deep validation rejects unknown relation keys, unknown subkeys, `limit` on many-to-one/one-to-one, and invalid dotted sort through many-side relations.',
@@ -323,7 +324,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
323
324
  '- **HTTP method management:** use the dedicated MCP tools `list_methods`, `create_method`, `update_method`, and preview-first `delete_method` for `method_definition`. The backend field is `method_definition.name`, unique per method; do not send `method_definition.method`. The eApp UI for the same records is `/settings/methods`. Method color fields are `buttonColor` for badge background and `textColor` for badge text, both full hex colors. Do not use generic `create_record` on `method_definition` unless the dedicated tool is unavailable.',
324
325
  '- **Extension navigation:** prefer `NuxtLink` or Nuxt UI components with `:to` for visible navigation links and drill-down cards/buttons. Use `navigateTo(...)` only for imperative navigation after submit, confirm, mutation, or another side effect.',
325
326
  '- **Extension runtime scope:** eApp exposes Vue APIs and injected Nuxt/Enfyra composables both to script global scope and Vue app `globalProperties`. Template expressions may call injected helpers directly, for example after a save handler can call `navigateTo("/data/report_definition")`, because Vue compiles template helpers to `_ctx.*`.',
326
- '- **Global extension lifecycle:** `extension_definition.type="global"` records are Vue SFC components mounted invisibly once at the eApp shell level. Use them for app-wide registrations such as account panel items, notification bells, admin socket listeners, and background refresh bridges. They do not need a menu, must not render page body UI, and should clean up socket/listener side effects with `onUnmounted`. Prefer registry composables such as `useAccountPanelRegistry`, `useHeaderActionRegistry`, or `useSubHeaderActionRegistry` instead of directly editing shell DOM.',
327
+ '- **Global extension lifecycle:** `extension_definition.type="global"` records are Vue SFC components mounted invisibly once at the eApp shell level. Use them for app-wide registrations such as account panel items, notification bells, admin socket listeners, and background refresh bridges. They do not need a menu, must not render page body UI, and should clean up socket/listener side effects with `onUnmounted`. Prefer registry composables such as `useAccountPanelRegistry`, `useHeaderActionRegistry`, or `useSubHeaderActionRegistry` instead of directly editing shell DOM. For account-panel entries, register data (`label`, `icon`, `description`, `badge`, `badgeColor`, `expanded`, `onToggle`, `contentComponent`) so eApp owns row spacing, icon sizing, badges, and expanded chrome; use a raw row `component` only for a true custom escape hatch.',
327
328
  '- **Extension realtime:** admin extensions can use `useAdminSocket()` to listen to the shared admin Socket.IO client instead of creating a separate browser socket. Use it for backend admin events such as operational status changes, debounce refreshes, and unsubscribe with `socket.off(...)` in `onUnmounted`. Guard generated code with `typeof useAdminSocket === "function"` when the extension may run on an older eApp build.',
328
329
  '- **Extension CSS affects shell utility ordering:** dynamic extension CSS is injected after the app shell CSS. Shell/page-header code must not put conflicting plain Tailwind utilities on the same element, such as `flex-col` plus `flex-row`, `items-start` plus `items-center`, or `text-left` plus `text-center`. Choose one mutually exclusive class per state; otherwise extension CSS can change which utility wins and shift shell layout.',
329
330
  '- **Admin record links:** when an admin extension links to backend records for management or inspection, point to eApp data routes such as `/data/report_definition` or `/data/order_definition`. Do not use public website paths from record fields unless the explicit intent is previewing the public website.',
@@ -361,7 +362,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
361
362
  '- **useConfirm:** Confirmation dialogs. Returns `{ confirm({ title, content, confirmText, cancelText }), isVisible, options, onConfirm, onCancel }`.',
362
363
  '- **useHeaderActionRegistry:** Register header actions. Use `const { register: registerHeaderActions } = useHeaderActionRegistry(); registerHeaderActions([{ id, label, onClick, color, icon, order, side, global, permission }])`. Action has `{ id, label, onClick, color, icon, order, side: \'left\'|\'right\', global, component, permission }`; admin actions should set `permission` by default.',
363
364
  '- **useSubHeaderActionRegistry:** Same as header but for sub-header: destructure `register` first, then call it with one action or an array.',
364
- '- **useAccountPanelRegistry:** Register rows in the sidebar account panel. Use `const { register } = useAccountPanelRegistry(); register({ id, order, component, props })` or pass an array. The caller lifecycle unregisters rows automatically, so global extensions can register notification/account controls safely.',
365
+ '- **useAccountPanelRegistry:** Register rows in the sidebar account panel. Destructure `const { register } = useAccountPanelRegistry()` and call `register(itemOrItems)`. Prefer data-driven items: `{ id, order, label, icon, description, badge, badgeColor, trailingIcon, expanded, onToggle, contentComponent, contentProps }`. eApp renders the row chrome and lifecycle-unregisters the caller automatically. Use `contentComponent` only for expanded inner content, and use raw `component` only when the entire row truly cannot use the shell contract.',
365
366
  '- **usePageHeaderRegistry:** Page title strip. `{ registerPageHeader, clearPageHeader, pageHeader, hasPageHeader }`. Config: `title`, optional `description`, `stats`, `variant`, `gradient` (`purple`|`blue`|`cyan`|`none` — horizontal strip + leading icon tint), `leadingIcon` (icon name), `hideLeadingIcon`. Call `registerPageHeader` again when title/stats must update (plain object snapshot, not refs inside the config).',
366
367
  '- **useMenuRegistry:** Menu management. Returns `{ menuItems, menuGroups, registerMenuItem, unregisterMenuItem, getMenuItemsBySidebar, findParentMenuIdByPath }`.',
367
368
  '- **useMenuApi:** Low-level menu API.',
@@ -144,6 +144,16 @@ export function normalizeRelationForTablePatch(relation) {
144
144
  return normalized;
145
145
  }
146
146
 
147
+ export function resolveRelationTargetsFromMetadata(metadata, relations) {
148
+ return relations.map((relation) => {
149
+ const targetTable = relation.targetTable;
150
+ if (typeof targetTable !== 'string' || !targetTable.trim()) return relation;
151
+ const resolvedTable = resolveTableFromMetadataByName(metadata, targetTable);
152
+ if (!resolvedTable) return relation;
153
+ return { ...relation, targetTable: getId(resolvedTable) };
154
+ });
155
+ }
156
+
147
157
  function getId(record) {
148
158
  return record?.id ?? record?._id ?? null;
149
159
  }
@@ -450,7 +460,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
450
460
  '**Not** for adding a custom API path or handler only — for that use **`create_route`** without `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
451
461
  'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
452
462
  'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","isRead","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
453
- 'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id or {id}.',
463
+ 'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id, {id}, or an exact table name that MCP resolves to an id before mutation.',
454
464
  'Do NOT provide physical FK/junction columns. Never include fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn. Enfyra derives and hides those physical columns from relation propertyName/table metadata.',
455
465
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
456
466
  'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
@@ -466,14 +476,18 @@ export function registerTableTools(server, ENFYRA_API_URL) {
466
476
  description: z.string().optional().describe('Description of what this table stores.'),
467
477
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
468
478
  columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, isPublished?, isUpdatable?, isEncrypted?, defaultValue?, description?, options? }. Set isEncrypted=true for values encrypted at rest; set isUpdatable=false separately only when the field should be immutable. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"api_key","type":"varchar","isEncrypted":true,"isPublished":false}]'),
469
- relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id or {"id": <id>}. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
479
+ relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id, {"id": <id>}, or an exact table name that MCP resolves to an id before mutation. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
470
480
  indexes: z.string().optional().describe('JSON array of logical index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Example: [["member","isRead","conversation"],["conversation","member","isRead"]]'),
471
481
  uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
472
482
  },
473
483
  async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
474
484
  const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
475
485
  const userColumns = parseJsonArrayParam('columns', columnsJson);
476
- const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
486
+ const parsedRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
487
+ const metadata = parsedRelations.length ? await fetchAPI(ENFYRA_API_URL, '/metadata') : null;
488
+ const userRelations = metadata
489
+ ? resolveRelationTargetsFromMetadata(metadata, parsedRelations)
490
+ : parsedRelations;
477
491
  const indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
478
492
  const uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
479
493
  const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };