@enfyra/mcp-server 0.0.55 → 0.0.57

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
@@ -184,6 +184,10 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
184
184
 
185
185
  `ENFYRA_API_TOKEN` is a long-lived programmatic token, not a JWT. MCP must never send it directly as `Authorization: Bearer <token>` to REST tools. The MCP client first calls `POST {ENFYRA_API_URL}/auth/token/exchange` with `{ "apiToken": ENFYRA_API_TOKEN }`, caches the returned `accessToken`, and uses that JWT as the Bearer token for subsequent requests.
186
186
 
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
+
189
+ For route contracts that intentionally keep workflow fields out of request bodies, generic `create_record`, `update_record`, and `delete_record` accept optional `queryParams` as a JSON object string. For example, Cloud admin project creation can keep `expired_at=YYYY-MM-DD` in the URL query while `validateBody` remains enabled for the table body.
190
+
187
191
  ### `ENFYRA_API_URL` — use the app proxy
188
192
 
189
193
  For normal apps and demos, set `ENFYRA_API_URL` to the Nuxt/app proxy:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lib/auth.js CHANGED
@@ -41,6 +41,10 @@ export function getAccessToken() {
41
41
  return accessToken;
42
42
  }
43
43
 
44
+ export function hasApiToken() {
45
+ return !!API_TOKEN;
46
+ }
47
+
44
48
  /**
45
49
  * Get token expiry time
46
50
  */
package/src/lib/fetch.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Handles API requests with auth, timeout, and error handling
4
4
  */
5
5
 
6
- import { getValidToken } from './auth.js';
6
+ import { getValidToken, hasApiToken, resetTokens } from './auth.js';
7
7
 
8
8
  // Timeout configuration
9
9
  const FETCH_TIMEOUT = 30000; // 30 seconds
@@ -17,49 +17,57 @@ const FETCH_TIMEOUT = 30000; // 30 seconds
17
17
  */
18
18
  export async function fetchAPI(apiUrl, path, options = {}) {
19
19
  const url = `${apiUrl}${path}`;
20
- const token = await getValidToken(apiUrl);
21
20
 
22
- const headersList = [
23
- ['Content-Type', 'application/json'],
24
- ['Authorization', `Bearer ${token}`],
25
- ];
21
+ async function requestWithCurrentToken() {
22
+ const token = await getValidToken(apiUrl);
23
+ const headersList = [
24
+ ['Content-Type', 'application/json'],
25
+ ['Authorization', `Bearer ${token}`],
26
+ ];
26
27
 
27
- if (options.headers) {
28
- const optHeaders = options.headers;
29
- for (const key of Object.keys(optHeaders)) {
30
- const existingIdx = headersList.findIndex(h => h[0] === key);
31
- if (existingIdx >= 0) {
32
- headersList[existingIdx] = [key, optHeaders[key]];
33
- } else {
34
- headersList.push([key, optHeaders[key]]);
28
+ if (options.headers) {
29
+ const optHeaders = options.headers;
30
+ for (const key of Object.keys(optHeaders)) {
31
+ const existingIdx = headersList.findIndex(h => h[0] === key);
32
+ if (existingIdx >= 0) {
33
+ headersList[existingIdx] = [key, optHeaders[key]];
34
+ } else {
35
+ headersList.push([key, optHeaders[key]]);
36
+ }
35
37
  }
36
38
  }
37
- }
38
-
39
- const controller = new AbortController();
40
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
41
39
 
42
- try {
43
- const res = await fetch(url, {
44
- ...options,
45
- headers: headersList,
46
- signal: controller.signal,
47
- });
48
- clearTimeout(timeoutId);
49
-
50
- if (!res.ok) {
51
- const error = await res.text().catch(() => res.statusText);
52
- throw new Error(`API error (${res.status}): ${error}`);
40
+ const controller = new AbortController();
41
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
42
+ try {
43
+ const res = await fetch(url, {
44
+ ...options,
45
+ headers: headersList,
46
+ signal: controller.signal,
47
+ });
48
+ clearTimeout(timeoutId);
49
+ return res;
50
+ } catch (error) {
51
+ clearTimeout(timeoutId);
52
+ if (error.name === 'AbortError') {
53
+ throw new Error(`Request timeout after ${FETCH_TIMEOUT}ms`);
54
+ }
55
+ throw error;
53
56
  }
57
+ }
54
58
 
55
- return res.json();
56
- } catch (error) {
57
- clearTimeout(timeoutId);
58
- if (error.name === 'AbortError') {
59
- throw new Error(`Request timeout after ${FETCH_TIMEOUT}ms`);
60
- }
61
- throw error;
59
+ let res = await requestWithCurrentToken();
60
+ if ((res.status === 401 || res.status === 403) && hasApiToken()) {
61
+ resetTokens();
62
+ res = await requestWithCurrentToken();
62
63
  }
64
+
65
+ if (!res.ok) {
66
+ const error = await res.text().catch(() => res.statusText);
67
+ throw new Error(`API error (${res.status}): ${error}`);
68
+ }
69
+
70
+ return res.json();
63
71
  }
64
72
 
65
73
  /**
@@ -203,10 +203,21 @@ create_column({
203
203
  defaultValue: "pending",
204
204
  isPublished: true,
205
205
  description: "Email verification state controlled by server hooks."
206
+ })
207
+
208
+ create_column({
209
+ tableId: "<project_env_table_id>",
210
+ name: "value",
211
+ type: "text",
212
+ isNullable: false,
213
+ isPublished: false,
214
+ isEncrypted: true,
215
+ description: "Encrypted environment value."
206
216
  })`,
207
217
  notes: [
208
218
  'Run schema-changing calls sequentially. Do not parallelize create_column calls.',
209
219
  'create_column fetches table_definition and patches only real persisted columns with id/_id; generated metadata projections such as createdAt, updatedAt, or relation FK display fields are skipped.',
220
+ 'Use isEncrypted=true for encryption at rest. Add isUpdatable=false separately only when the field should be immutable.',
210
221
  'Use hooks or field permissions to prevent clients from updating server-owned fields.',
211
222
  ],
212
223
  },
@@ -107,6 +107,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
107
107
  '',
108
108
  '### Relation field format (create_record / update_record)',
109
109
  '- For generic MCP `create_record` and `update_record`, the `data` argument is a **JSON string**, not a JavaScript object. Example: `data: "{\\"name\\":\\"Starter\\"}"`. If the host gives a validation error saying `data` expected string, stringify the object before calling the tool.',
110
+ '- Generic MCP `create_record` and `update_record` validate body keys against live metadata before sending REST. If a field such as `expiredAt` is not in metadata, do not bypass body validation; add the metadata field through schema tools or pass route workflow fields in the tool `queryParams` JSON when that route explicitly owns a query contract such as `{"expired_at":"2026-09-20"}`.',
111
+ '- Generic MCP script-table mutations reject `compiledCode`, reject legacy `code` aliases, and must validate `sourceCode` with `/admin/script/validate` before saving. If validation is unavailable or fails, the save must fail closed. Prefer dedicated `create_handler`, `create_pre_hook`, and `create_post_hook` tools for route code.',
112
+ '- Generic MCP `delete_record` is destructive but preview-first: the first call without `confirm=true` returns the target preview; call it again with `confirm=true` only after the user explicitly approves deletion. Use `queryParams` for route-specific confirmation contracts instead of putting those fields in the body.',
110
113
  '- Relation fields (publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
111
114
  '- **mainTable warning:** do not set `mainTable` on custom routes. It is reserved for canonical table routes only.',
112
115
  ' - **Many-to-one:** `"someRelation": {"id": 4}` (single object with id)',
@@ -136,7 +139,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
136
139
  '### Dynamic script syntax preference',
137
140
  '- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.',
138
141
  '- Use Enfyra native throw helpers for intentional errors: `@THROW400("message")`, `@THROW403()`, `@THROW404("resource", id)`, or `$ctx.$throw[400]("message")`. Do not generate `throw new Error(...)` for user/domain errors in handlers, hooks, flows, websocket events, OAuth scripts, or admin-generated scripts.',
139
- '- For encrypted persisted fields such as `*_encrypted`, use an Enfyra route pre-hook, not a Knex/database hook. Mutate the body before persistence: `const value = @BODY.field_encrypted; if (value && value.slice(0, 7) !== "enc:v1:") @BODY.field_encrypted = @HELPERS.$encrypt.encrypt(value);`.',
142
+ '- For regular app data that must be encrypted at rest, create the column with `isEncrypted=true`; Enfyra database-query hooks will encrypt on insert/update and decrypt after select. `isEncrypted` does not imply immutability; use `isUpdatable=false` separately only when the field itself must be immutable. Do not filter or sort on encrypted fields. Legacy Cloud control-plane fields named `*_encrypted` may still use route pre-hooks until migrated.',
140
143
  '- ASV exposes `$helpers.$encrypt.encrypt/decrypt` for encrypted strings and `$helpers.$ssh.generateKeyPair` for SSH keys. Do not generate `$helpers.$secrets` usage.',
141
144
  '- Script-backed records use one shared persistence contract: `sourceCode` is the editable source, `scriptLanguage` controls compilation, and `compiledCode` is generated by the server from `sourceCode`. Do not hand-edit or send stale `compiledCode` from generated tools; save `sourceCode`/`scriptLanguage` through `PATCH /<script_table>/<id>` and let the server persist generated `compiledCode` internally. Public metadata may mark `compiledCode` non-updatable, but the server engine must still preserve the generated value after normalization.',
142
145
  '- For route handlers specifically, the field is also `sourceCode`. Older names such as `logic` are wrong for current Enfyra REST CRUD and will be rejected. Use MCP `create_handler` so it writes `sourceCode` and resolves method ids correctly.',
@@ -202,8 +205,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
202
205
  '- To check which tables have canonical CRUD routes, call `get_all_routes` and look for `mainTable`. Custom routes intentionally have no `mainTable`; inspect their handlers/hooks to see which repos they touch.',
203
206
  '- **Tables confirmed to have REST routes (system):** `bootstrap_script_definition`, `column_rule_definition`, `cors_origin_definition`, `extension_definition`, `field_permission_definition`, `file_definition`, `file_permission_definition`, `flow_definition`, `flow_execution_definition`, `flow_step_definition`, `folder_definition`, `gql_definition`, `guard_definition`, `guard_rule_definition`, `menu_definition`, `method_definition`, `oauth_account_definition`, `oauth_config_definition`, `package_definition`, `post_hook_definition`, `pre_hook_definition`, `relation_definition`, `role_definition`, `route_definition`, `route_handler_definition`, `route_permission_definition`, `schema_migration_definition`, `setting_definition`, `storage_config_definition`, `table_definition`, `user_definition`, `websocket_definition`, `websocket_event_definition`.',
204
207
  '- **Tables without REST routes (internal/system only):** `column_definition`, `session_definition`. Columns are managed indirectly via cascade on `table_definition` (POST/PATCH with columns arrays). The `create_table`, `create_column`/`add_column`, `update_column`, and `delete_column`/`remove_column` MCP tools handle this automatically by reading full table metadata first.',
205
- '- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isPublished=false` directly when creating secret/internal fields such as `*_encrypted`. When patching an existing table, only persisted columns with an `id`/`_id` belong in the cascade payload; metadata projections such as `createdAt`, `updatedAt`, or relation-derived FK display fields without an id are not valid column-definition patch rows. Never rebuild a schema cascade from `table_definition?fields=columns.*`, because nested relation fields may be paginated/truncated.',
206
- '- Prefer `create_relation`/`add_relation` and `delete_relation`/`remove_relation` for relation schema changes because they preserve the full table relation list and handle schema-confirm retry. Direct `create_record` on `relation_definition` only edits metadata and is not the canonical schema migration path.',
208
+ '- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isUpdatable`, `isEncrypted`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isUpdatable=false` for immutable fields and set `isPublished=false` directly when creating secret/internal fields. `isEncrypted=true` encrypts stored values at rest but does not change `isUpdatable`; encrypted fields must not be used for filter/sort. When patching an existing table, only persisted columns with an `id`/`_id` belong in the cascade payload; metadata projections such as `createdAt`, `updatedAt`, or relation-derived FK display fields without an id are not valid column-definition patch rows. Never rebuild a schema cascade from `table_definition?fields=columns.*`, because nested relation fields may be paginated/truncated.',
209
+ '- Prefer `create_relation`/`add_relation` and `delete_relation`/`remove_relation` for relation schema changes because they preserve the full table relation list, serialize schema mutations, verify unrelated relation ids survived, and handle schema-confirm retry. Direct `create_record` on `relation_definition` only edits metadata and is not the canonical schema migration path; generic record mutations also reject physical FK/junction fields on `relation_definition`.',
210
+ '- Destructive schema tools and generic `delete_record` return a preview unless `confirm=true` is passed. This applies to `delete_record`, `delete_table`, `delete_column`/`remove_column`, and `delete_relation`/`remove_relation`; do not add `confirm=true` until the user has explicitly approved the destructive operation.',
207
211
  '',
208
212
  '### Body validation & column rules',
209
213
  '- Each `table_definition` has a **`validateBody`** flag (default `true` for new tables). When on, every `POST /<table>` and `PATCH /<table>/<id>` is validated server-side against the column types + any **column rules** attached to columns of that table.',
@@ -310,9 +314,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
310
314
  '- **Admin menu visibility is permission-driven, not RLS:** Cloud/admin menu entries are sensitive and must set `menu_definition.permission` so they are visible only to users who have at least GET permission for the backing route or table. Permission conditions use HTTP `methods`, not CRUD `actions`. Do not show a Cloud menu merely because an extension exists or because the path is hardcoded. Example: `/cloud/hosts` menu should require `{ or: [{ route: "/cloud/admin/hosts", methods: ["GET"] }, { route: "/cloud_servers", methods: ["GET"] }] }`.',
311
315
  '- **PermissionGate is mandatory inside admin extensions:** every sensitive action button, form, mutation, destructive workflow, and data shortcut must be wrapped in `PermissionGate` or guarded with `usePermissions()` before rendering/enabling. Default gates: list/detail visibility needs `methods: ["GET"]`; create and custom flow-trigger routes usually need `methods: ["POST"]`; native record edits need `methods: ["PATCH"]`; native delete routes need `methods: ["DELETE"]`. Root admin still passes through normal permission helpers, but extension code must not rely on root-only assumptions.',
312
316
  '- **Extension permission UX:** if the current user can read a page but cannot perform an action, hide the action by default. If hiding would confuse the workflow, render a disabled state with a short reason. Never let the button render active and depend only on the server rejection; server permissions are the final boundary, not the UI contract.',
313
- '- **Cloud project admin operations:** use canonical `cloud_projects` table routes as the single source of truth. Admin manual create uses `POST /cloud_projects` with schema-safe fields `owner: { id }`, `plan: { id }`, `expiredAt`, and `status: "creating"`. The UI searches/selects `user_definition` by email and selects a plan by card; do not ask the operator to type raw user ids, plan ids, project names, subdomains, tenant admin emails, or passwords when the handler can derive them server-side. Project detail is the place for destructive lifecycle actions. Disable uses `PATCH /cloud_projects/:id` with body `{ status: "disabled" }` and `confirm_tenant_id`/`confirm_hash` in query params. Enable uses `PATCH /cloud_projects/:id` with body `{ status: "running" }`. Renew uses `PATCH /cloud_projects/:id` with a future `expiredAt`. Delete uses `DELETE /cloud_projects/:id` with typed `confirm_tenant_id`, returned `requiredConfirmHash`, and the matching hash before triggering `cloud-delete-project`. Do not create separate one-off `/cloud/admin/projects/*` action routes for create/disable/enable/delete when the canonical table route can own the workflow.',
317
+ '- **Cloud project admin operations:** use canonical `cloud_projects` table routes as the single source of truth. Admin manual create uses `POST /cloud_projects` with schema-safe body fields such as `owner: { id }`, `plan: { id }`, and `status: "creating"`; expiry is passed as query `expired_at=YYYY-MM-DD`, not body `expiredAt`. The UI searches/selects `user_definition` by email and selects a plan by card; do not ask the operator to type raw user ids, plan ids, project names, subdomains, tenant admin emails, or passwords when the handler can derive them server-side. Project detail is the place for destructive lifecycle actions. Disable uses `PATCH /cloud_projects/:id` with body `{ status: "disabled" }` and `confirm_tenant_id`/`confirm_hash` in query params. Enable uses `PATCH /cloud_projects/:id` with body `{ status: "running" }`. Renew uses the canonical renew flow/query contract and writes `cloud_project_renewals`; do not add expiry columns to `cloud_projects`. Delete uses `DELETE /cloud_projects/:id` with typed `confirm_tenant_id`, returned `requiredConfirmHash`, and the matching hash before triggering `cloud-delete-project`. Do not create separate one-off `/cloud/admin/projects/*` action routes for create/disable/enable/delete when the canonical table route can own the workflow.',
314
318
  '- **Cloud admin terminology:** in Cloud admin UI, call physical tenant workloads "projects" everywhere. Do not label creation or details as "instance" unless the user explicitly asks for that word.',
315
- '- **Cloud project create UI:** manual Cloud project creation should use `CommonDrawer`, not a wide modal. Let the operator search/select `user_definition` by email and select a plan with cards; do not expose duplicate free-text `user id` or `plan id` inputs when selectors exist. Prefer sending only the selected owner id, plan id, and required workflow fields such as `expiredAt` when the canonical handler can derive customer email, project name, subdomain, and password. Expiry selection should use quick presets plus a manual calendar (`UCalendar` when available, loaded through `install_package`/`getPackages` if an app package is needed).',
319
+ '- **Cloud project create UI:** manual Cloud project creation should use `CommonDrawer`, not a wide modal. Let the operator search/select `user_definition` by email and select a plan with cards; do not expose duplicate free-text `user id` or `plan id` inputs when selectors exist. Prefer sending only the selected owner id and plan id in the schema-safe body; pass expiry as query `expired_at=YYYY-MM-DD` when the canonical handler requires it and let the handler derive customer email, project name, subdomain, and password. Expiry selection should use quick presets plus a manual calendar (`UCalendar` when available, loaded through `install_package`/`getPackages` if an app package is needed).',
316
320
  '- **Cloud host settings and creation UI:** host settings store only provider selection codes Enfyra controls, currently location and server type. Do not expose or save provider-derived RAM, disk, vCPU, or cost values by hand. Query the provider catalog route, show real package/location cards, support load-more/search when the list is long, and snapshot provider facts onto `cloud_servers` only during host creation.',
317
321
  '- **Flow schedule UI:** schedule trigger editors must keep the server contract as `triggerConfig.cron` and `triggerConfig.timezone`, but the UI should not be a bare cron field plus giant timezone dropdown. Provide common cadence presets, readable current-schedule summary, searchable access to all IANA timezones, suggested timezone shortcuts, and a custom cron escape hatch so operators can configure recurring checks without remembering cron syntax.',
318
322
  '- **Admin operation UI:** use eApp `CommonModal` for compact create, disable, delete, and multi-field confirmation workflows. Use `CommonDrawer` for longer setup workflows such as Cloud host settings, host creation, project creation, and provider/package selection. Open the modal/drawer immediately on click, then render loading/error/content inside it; do not wait for async fetches to finish before showing the shell.',
@@ -410,9 +414,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
410
414
  `- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args)`,
411
415
  `- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
412
416
  `- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
413
- `- \`create_record\` → POST \`${base}/<tableName>\``,
414
- `- \`update_record\` → PATCH \`${base}/<tableName>/<id>\``,
415
- `- \`delete_record\` → DELETE \`${base}/<tableName>/<id>\``,
417
+ `- \`create_record\` → POST \`${base}/<tableName>\` (optional tool queryParams append URL query)`,
418
+ `- \`update_record\` → PATCH \`${base}/<tableName>/<id>\` (optional tool queryParams append URL query)`,
419
+ `- \`delete_record\` → DELETE \`${base}/<tableName>/<id>\` after preview + confirm=true (optional tool queryParams append URL query)`,
416
420
  `- \`create_extension\` → POST \`${base}/extension_definition\` (Vue SFC only; for page pass menuId). \`update_record\` on extension_definition to change code.`,
417
421
  `- Flow tables: \`${base}/flow_definition\`, \`${base}/flow_step_definition\`, \`${base}/flow_execution_definition\` — use standard CRUD tools.`,
418
422
  `- \`run_admin_test\` → POST \`${base}/admin/test/run\``,
@@ -0,0 +1,118 @@
1
+ const SCRIPT_TABLES = new Set([
2
+ 'route_handler_definition',
3
+ 'pre_hook_definition',
4
+ 'post_hook_definition',
5
+ 'flow_step_definition',
6
+ 'websocket_event_definition',
7
+ 'websocket_definition',
8
+ 'gql_definition',
9
+ 'bootstrap_script_definition',
10
+ ]);
11
+
12
+ const CODE_ALIAS_FORBIDDEN_TABLES = new Set([
13
+ 'route_handler_definition',
14
+ 'pre_hook_definition',
15
+ 'post_hook_definition',
16
+ 'flow_step_definition',
17
+ 'websocket_event_definition',
18
+ 'websocket_definition',
19
+ 'gql_definition',
20
+ 'bootstrap_script_definition',
21
+ ]);
22
+
23
+ const FORBIDDEN_RELATION_DEFINITION_KEYS = new Set([
24
+ 'fkCol',
25
+ 'fkColumn',
26
+ 'foreignKeyColumn',
27
+ 'sourceColumn',
28
+ 'targetColumn',
29
+ 'junctionSourceColumn',
30
+ 'junctionTargetColumn',
31
+ ]);
32
+
33
+ export function parseRecordData(data) {
34
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
35
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
36
+ throw new Error('Record data must be a JSON object string.');
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ export function getAllowedMutationFields(table) {
42
+ const columns = (table?.columns || []).map((column) => column.name).filter(Boolean);
43
+ const relations = (table?.relations || []).map((relation) => relation.propertyName).filter(Boolean);
44
+ return new Set([...columns, ...relations]);
45
+ }
46
+
47
+ export function validatePayloadFields(table, payload) {
48
+ const allowed = getAllowedMutationFields(table);
49
+ if (allowed.size === 0) return;
50
+
51
+ const unknown = Object.keys(payload).filter((key) => !allowed.has(key));
52
+ if (unknown.length > 0) {
53
+ throw new Error(
54
+ `Payload contains fields not present in metadata for ${table.name}: ${unknown.join(', ')}. ` +
55
+ `Use metadata-backed fields only, or create the field through schema tools first. Known fields: ${[...allowed].sort().join(', ')}`
56
+ );
57
+ }
58
+ }
59
+
60
+ export function rejectUnsafeScriptPayload(tableName, payload) {
61
+ if (Object.prototype.hasOwnProperty.call(payload, 'compiledCode')) {
62
+ throw new Error('Do not send compiledCode. Save sourceCode/scriptLanguage and let Enfyra compile compiledCode.');
63
+ }
64
+ if (CODE_ALIAS_FORBIDDEN_TABLES.has(tableName) && Object.prototype.hasOwnProperty.call(payload, 'code')) {
65
+ throw new Error(`Do not send code to ${tableName}. Use sourceCode/scriptLanguage, or the dedicated MCP create_* tool for this script surface.`);
66
+ }
67
+ }
68
+
69
+ export function rejectUnsafeRelationDefinitionPayload(tableName, payload) {
70
+ if (tableName !== 'relation_definition') return;
71
+ const forbidden = Object.keys(payload).filter((key) => FORBIDDEN_RELATION_DEFINITION_KEYS.has(key));
72
+ if (forbidden.length > 0) {
73
+ throw new Error(
74
+ `Do not send physical FK/junction fields to relation_definition: ${forbidden.join(', ')}. ` +
75
+ 'Use create_relation/add_relation with targetTable/type/propertyName; Enfyra derives physical columns.'
76
+ );
77
+ }
78
+ }
79
+
80
+ export async function validateScriptSourceIfPresent(fetchAPI, apiUrl, tableName, payload) {
81
+ if (!SCRIPT_TABLES.has(tableName) || typeof payload.sourceCode !== 'string') {
82
+ return { validated: false, reason: 'no script source' };
83
+ }
84
+
85
+ try {
86
+ const result = await fetchAPI(apiUrl, '/admin/script/validate', {
87
+ method: 'POST',
88
+ body: JSON.stringify({
89
+ sourceCode: payload.sourceCode,
90
+ scriptLanguage: payload.scriptLanguage || 'javascript',
91
+ }),
92
+ });
93
+ if (result?.valid === false || result?.success === false) {
94
+ throw new Error(result?.error?.message || 'Script validation failed.');
95
+ }
96
+ return { validated: true, skipped: false };
97
+ } catch (error) {
98
+ const message = String(error?.message || error);
99
+ throw new Error(`Script validation failed before save: ${message}`);
100
+ }
101
+ }
102
+
103
+ export async function prepareRecordMutation({ fetchAPI, apiUrl, tables, tableName, data }) {
104
+ const payload = parseRecordData(data);
105
+ const table = tables.find((item) => item?.name === tableName || item?.alias === tableName);
106
+ if (!table) throw new Error(`Unknown table "${tableName}"`);
107
+
108
+ validatePayloadFields(table, payload);
109
+ rejectUnsafeScriptPayload(table.name, payload);
110
+ rejectUnsafeRelationDefinitionPayload(table.name, payload);
111
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, apiUrl, table.name, payload);
112
+
113
+ return {
114
+ table,
115
+ payload,
116
+ scriptValidation,
117
+ };
118
+ }
@@ -0,0 +1,24 @@
1
+ function getId(item) {
2
+ return item?.id ?? item?._id ?? null;
3
+ }
4
+
5
+ function sameId(a, b) {
6
+ return String(a) === String(b);
7
+ }
8
+
9
+ export function validateMainTableRoutePath(tables, mainTableId, routePath) {
10
+ const table = tables.find((item) => sameId(getId(item), mainTableId));
11
+ if (!table) {
12
+ throw new Error(`Unknown table id "${mainTableId}"`);
13
+ }
14
+
15
+ const canonicalPath = `/${table.name}`;
16
+ if (routePath !== canonicalPath) {
17
+ throw new Error(
18
+ `mainTableId is only allowed for canonical table route "${canonicalPath}". ` +
19
+ `Omit mainTableId for custom route "${routePath}" and query explicit repos in handlers/hooks.`
20
+ );
21
+ }
22
+
23
+ return table;
24
+ }
@@ -4,6 +4,24 @@
4
4
  import { z } from 'zod';
5
5
  import { fetchAPI } from './fetch.js';
6
6
 
7
+ let schemaQueue = Promise.resolve();
8
+
9
+ function withSchemaQueue(operation) {
10
+ const run = schemaQueue.then(operation, operation);
11
+ schemaQueue = run.catch(() => {});
12
+ return run;
13
+ }
14
+
15
+ const FORBIDDEN_RELATION_KEYS = [
16
+ 'fkCol',
17
+ 'fkColumn',
18
+ 'foreignKeyColumn',
19
+ 'sourceColumn',
20
+ 'targetColumn',
21
+ 'junctionSourceColumn',
22
+ 'junctionTargetColumn',
23
+ ];
24
+
7
25
  export function normalizeTablesFromMetadata(metadata) {
8
26
  const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
9
27
  return Array.isArray(tablesSource)
@@ -81,7 +99,12 @@ function normalizeConstraintGroups(name, groups) {
81
99
  });
82
100
  }
83
101
 
84
- function normalizeRelationForTablePatch(relation) {
102
+ export function normalizeRelationForTablePatch(relation) {
103
+ for (const key of FORBIDDEN_RELATION_KEYS) {
104
+ if (Object.prototype.hasOwnProperty.call(relation, key)) {
105
+ throw new Error(`Relation schema must not include physical column field "${key}". Use propertyName/targetTable only; Enfyra derives FK and junction columns.`);
106
+ }
107
+ }
85
108
  const {
86
109
  sourceTable,
87
110
  targetTable,
@@ -163,12 +186,36 @@ async function verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
163
186
  return afterColumns;
164
187
  }
165
188
 
166
- function buildColumnDefinition({
189
+ async function verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
190
+ action,
191
+ relationId,
192
+ propertyName,
193
+ }) {
194
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
195
+ const afterRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
196
+ const afterIds = afterRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
197
+ const excludedIds = action === 'delete' ? [relationId] : [];
198
+ const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
199
+ if (missingIds.length > 0) {
200
+ throw new Error(`Schema cascade verification failed: unrelated relation ids disappeared: ${missingIds.join(', ')}`);
201
+ }
202
+ if (action === 'create' && !afterRelations.some((relation) => relation.propertyName === propertyName)) {
203
+ throw new Error(`Schema cascade verification failed: relation "${propertyName}" was not found after create.`);
204
+ }
205
+ if (action === 'delete' && afterIds.includes(String(relationId))) {
206
+ throw new Error(`Schema cascade verification failed: relation ${relationId} still exists after delete.`);
207
+ }
208
+ return afterRelations;
209
+ }
210
+
211
+ export function buildColumnDefinition({
167
212
  name,
168
213
  type,
169
214
  isNullable,
170
215
  isUnique,
171
216
  isPublished,
217
+ isUpdatable,
218
+ isEncrypted,
172
219
  isPrimary,
173
220
  isGenerated,
174
221
  isSystem,
@@ -180,6 +227,8 @@ function buildColumnDefinition({
180
227
  if (isNullable !== undefined) column.isNullable = isNullable;
181
228
  if (isUnique !== undefined) column.isUnique = isUnique;
182
229
  if (isPublished !== undefined) column.isPublished = isPublished;
230
+ if (isUpdatable !== undefined) column.isUpdatable = isUpdatable;
231
+ if (isEncrypted !== undefined) column.isEncrypted = isEncrypted;
183
232
  if (isPrimary !== undefined) column.isPrimary = isPrimary;
184
233
  if (isGenerated !== undefined) column.isGenerated = isGenerated;
185
234
  if (isSystem !== undefined) column.isSystem = isSystem;
@@ -196,6 +245,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
196
245
  const apiBase = ENFYRA_API_URL.replace(/\/$/, '');
197
246
 
198
247
  async function appendColumnToTable(args) {
248
+ return withSchemaQueue(async () => {
199
249
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, args.tableId);
200
250
  if (!tableData) {
201
251
  return { content: [{ type: 'text', text: `Error: Table with ID ${args.tableId} not found.` }] };
@@ -213,14 +263,17 @@ export function registerTableTools(server, ENFYRA_API_URL) {
213
263
  return {
214
264
  content: [{ type: 'text', text: `Column "${args.name}" added to table ${args.tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
215
265
  };
266
+ });
216
267
  }
217
268
 
218
269
  async function appendRelationToTable({ sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description }) {
270
+ return withSchemaQueue(async () => {
219
271
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, sourceTableId);
220
272
  if (!tableData) {
221
273
  return { content: [{ type: 'text', text: `Error: Table with ID ${sourceTableId} not found.` }] };
222
274
  }
223
275
  const existingRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
276
+ const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
224
277
  const newRelation = { targetTable: targetTableId, type, propertyName };
225
278
  if (inversePropertyName !== undefined) newRelation.inversePropertyName = inversePropertyName || null;
226
279
  if (mappedBy !== undefined) newRelation.mappedBy = mappedBy;
@@ -228,12 +281,18 @@ export function registerTableTools(server, ENFYRA_API_URL) {
228
281
  if (onDelete !== undefined) newRelation.onDelete = onDelete;
229
282
  if (description !== undefined) newRelation.description = description;
230
283
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, sourceTableId, { relations: [...existingRelations, newRelation] });
284
+ await verifyRelationCascade(ENFYRA_API_URL, sourceTableId, beforeIds, {
285
+ action: 'create',
286
+ propertyName,
287
+ });
231
288
  return {
232
289
  content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${sourceTableId} → ${targetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
233
290
  };
291
+ });
234
292
  }
235
293
 
236
- async function removeColumnFromTable({ tableId, columnId }) {
294
+ async function removeColumnFromTable({ tableId, columnId, confirm }) {
295
+ return withSchemaQueue(async () => {
237
296
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
238
297
  if (!tableData) {
239
298
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
@@ -244,6 +303,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
244
303
  if (!beforeIds.includes(String(columnId))) {
245
304
  throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
246
305
  }
306
+ if (!confirm) {
307
+ const target = existingColumns.find((column) => String(getId(column)) === String(columnId));
308
+ return {
309
+ content: [{ type: 'text', text: JSON.stringify({
310
+ action: 'delete_column_preview',
311
+ tableId,
312
+ columnId,
313
+ targetColumn: target,
314
+ preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
315
+ destructive: true,
316
+ next: 'Call delete_column/remove_column again with confirm=true to drop the physical column and metadata.',
317
+ }, null, 2) }],
318
+ };
319
+ }
247
320
 
248
321
  const columns = existingColumns
249
322
  .filter(col => String(getId(col)) !== String(columnId))
@@ -257,23 +330,49 @@ export function registerTableTools(server, ENFYRA_API_URL) {
257
330
  return {
258
331
  content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
259
332
  };
333
+ });
260
334
  }
261
335
 
262
- async function removeRelationFromTable({ tableId, relationId }) {
336
+ async function removeRelationFromTable({ tableId, relationId, confirm }) {
337
+ return withSchemaQueue(async () => {
263
338
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
264
339
  if (!tableData) {
265
340
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
266
341
  }
267
342
 
268
- const relations = (tableData.relations || [])
269
- .filter(rel => String(rel.id) !== String(relationId))
270
- .map(normalizeRelationForTablePatch);
343
+ const existingRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
344
+ const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
345
+ if (!beforeIds.includes(String(relationId))) {
346
+ throw new Error(`Relation ${relationId} was not found on table ${tableId}; refusing schema cascade patch.`);
347
+ }
348
+ if (!confirm) {
349
+ const target = existingRelations.find((relation) => String(getId(relation)) === String(relationId));
350
+ return {
351
+ content: [{ type: 'text', text: JSON.stringify({
352
+ action: 'delete_relation_preview',
353
+ tableId,
354
+ relationId,
355
+ targetRelation: target,
356
+ preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
357
+ destructive: true,
358
+ next: 'Call delete_relation/remove_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
359
+ }, null, 2) }],
360
+ };
361
+ }
362
+
363
+ const relations = existingRelations
364
+ .filter(rel => String(getId(rel)) !== String(relationId))
271
365
 
272
366
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { relations });
367
+ await verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
368
+ action: 'delete',
369
+ relationId,
370
+ });
273
371
 
274
372
  return {
275
373
  content: [{ type: 'text', text: `Relation ${relationId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
276
374
  };
375
+ });
277
376
  }
278
377
 
279
378
  const columnCreateSchema = {
@@ -283,6 +382,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
283
382
  isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
284
383
  isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
285
384
  isPublished: z.boolean().optional().describe('Set column visibility baseline. Use false for secrets and internal fields.'),
385
+ isUpdatable: z.boolean().optional().describe('Set false for immutable fields that cannot be updated after creation. Independent from isEncrypted.'),
386
+ isEncrypted: z.boolean().optional().describe('Set true to encrypt this column at the Enfyra database-query layer. This does not change isUpdatable. Encrypted fields cannot be filtered or sorted.'),
286
387
  isPrimary: z.boolean().optional().describe('Set true only for primary key columns; normally only create_table auto id uses this.'),
287
388
  isGenerated: z.boolean().optional().describe('Set true only for generated columns such as auto id.'),
288
389
  isSystem: z.boolean().optional().describe('Set true only for system-managed columns. Avoid for normal app fields.'),
@@ -306,11 +407,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
306
407
  const columnDeleteSchema = {
307
408
  tableId: z.string().describe('Table definition ID.'),
308
409
  columnId: z.string().describe('Column definition ID to delete.'),
410
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
309
411
  };
310
412
 
311
413
  const relationDeleteSchema = {
312
414
  tableId: z.string().describe('Table definition ID (source table of the relation).'),
313
415
  relationId: z.string().describe('Relation definition ID to delete.'),
416
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
314
417
  };
315
418
 
316
419
  // ─── READ ───
@@ -351,12 +454,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
351
454
  name: z.string().describe('Table name (e.g., "user_definition", "my_custom_table"). Must be unique, lowercase with underscores.'),
352
455
  description: z.string().optional().describe('Description of what this table stores.'),
353
456
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
354
- columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, defaultValue?, description?, options? }. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"status","type":"enum","options":["draft","published"]}]'),
457
+ 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}]'),
355
458
  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"}]'),
356
459
  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"]]'),
357
460
  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"]]'),
358
461
  },
359
- async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
462
+ async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
360
463
  const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
361
464
  const userColumns = parseJsonArrayParam('columns', columnsJson);
362
465
  const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
@@ -391,7 +494,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
391
494
  return {
392
495
  content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
393
496
  };
394
- }
497
+ })
395
498
  );
396
499
 
397
500
  // ─── UPDATE TABLE ───
@@ -414,7 +517,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
414
517
  indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on table_definition.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
415
518
  uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on table_definition.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
416
519
  },
417
- async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => {
520
+ async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
418
521
  const body = {};
419
522
  if (name !== undefined) body.name = name;
420
523
  if (alias !== undefined) body.alias = alias;
@@ -428,7 +531,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
428
531
  return {
429
532
  content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
430
533
  };
431
- }
534
+ })
432
535
  );
433
536
 
434
537
  // ─── DELETE TABLE ───
@@ -442,15 +545,30 @@ export function registerTableTools(server, ENFYRA_API_URL) {
442
545
  ].join(' '),
443
546
  {
444
547
  tableId: z.string().describe('Table definition ID to delete.'),
548
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
445
549
  },
446
- async ({ tableId }) => {
550
+ async ({ tableId, confirm }) => withSchemaQueue(async () => {
551
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
552
+ if (!confirm) {
553
+ return {
554
+ content: [{ type: 'text', text: JSON.stringify({
555
+ action: 'delete_table_preview',
556
+ tableId,
557
+ tableName: tableData.name,
558
+ columnCount: (tableData.columns || []).length,
559
+ relationCount: (tableData.relations || []).length,
560
+ destructive: true,
561
+ next: 'Call delete_table again with confirm=true to delete metadata, routes, derived FK/junction structures, the physical table, and all table data.',
562
+ }, null, 2) }],
563
+ };
564
+ }
447
565
  const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
448
566
  method: 'DELETE',
449
567
  });
450
568
  return {
451
569
  content: [{ type: 'text', text: `Table ${tableId} deleted.\n\n${JSON.stringify(result, null, 2)}` }],
452
570
  };
453
- }
571
+ })
454
572
  );
455
573
 
456
574
  // ─── CREATE COLUMN ───
@@ -499,11 +617,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
499
617
  type: z.string().optional().describe('New column type.'),
500
618
  isNullable: z.boolean().optional().describe('Set nullable.'),
501
619
  isPublished: z.boolean().optional().describe('Set column visibility baseline. false = unpublished (omitted from response unless allowed by field permission rules).'),
620
+ isUpdatable: z.boolean().optional().describe('Set false for immutable fields that should be stripped from update payloads.'),
502
621
  defaultValue: z.string().optional().describe('New default value as JSON string.'),
503
622
  description: z.string().optional().describe('New description.'),
504
623
  options: z.string().optional().describe('New options as JSON string.'),
505
624
  },
506
- async ({ tableId, columnId, name, type, isNullable, isPublished, defaultValue, description, options }) => {
625
+ async ({ tableId, columnId, name, type, isNullable, isPublished, isUpdatable, defaultValue, description, options }) => withSchemaQueue(async () => {
507
626
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
508
627
  if (!tableData) {
509
628
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
@@ -522,6 +641,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
522
641
  if (type !== undefined) rest.type = type;
523
642
  if (isNullable !== undefined) rest.isNullable = isNullable;
524
643
  if (isPublished !== undefined) rest.isPublished = isPublished;
644
+ if (isUpdatable !== undefined) rest.isUpdatable = isUpdatable;
525
645
  if (defaultValue !== undefined) rest.defaultValue = defaultValue;
526
646
  if (description !== undefined) rest.description = description;
527
647
  if (options !== undefined) rest.options = JSON.parse(options);
@@ -538,7 +658,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
538
658
  return {
539
659
  content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
540
660
  };
541
- }
661
+ })
542
662
  );
543
663
 
544
664
  // ─── DELETE COLUMN ───
@@ -19,6 +19,8 @@ import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
19
19
  import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
20
20
  import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
21
21
  import { registerTableTools } from './lib/table-tools.js';
22
+ import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
23
+ import { validateMainTableRoutePath } from './lib/route-guards.js';
22
24
 
23
25
  // Initialize auth module
24
26
  initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
@@ -173,6 +175,8 @@ function summarizeTable(table) {
173
175
  isPrimary: !!column.isPrimary,
174
176
  isNullable: column.isNullable,
175
177
  isPublished: column.isPublished,
178
+ isUpdatable: column.isUpdatable !== false,
179
+ isEncrypted: column.isEncrypted === true,
176
180
  })),
177
181
  relations: (table.relations || []).map((relation) => ({
178
182
  id: relation.id ?? relation._id,
@@ -323,6 +327,35 @@ function resolveFieldOrThrow(table, fieldName, kind = 'column') {
323
327
  return field;
324
328
  }
325
329
 
330
+ async function prepareGenericMutation(tableName, data) {
331
+ const { tables } = await getMetadataTables();
332
+ return prepareRecordMutation({
333
+ fetchAPI,
334
+ apiUrl: ENFYRA_API_URL,
335
+ tables,
336
+ tableName,
337
+ data,
338
+ });
339
+ }
340
+
341
+ function parseQueryParamsArg(queryParams) {
342
+ const parsed = parseJsonArg(queryParams, {});
343
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
344
+ throw new Error('queryParams must be a JSON object string.');
345
+ }
346
+ const params = new URLSearchParams();
347
+ for (const [key, value] of Object.entries(parsed)) {
348
+ if (value === undefined || value === null) continue;
349
+ params.set(key, String(value));
350
+ }
351
+ return params.toString();
352
+ }
353
+
354
+ function appendQuery(path, queryParams) {
355
+ if (!queryParams) return path;
356
+ return `${path}${path.includes('?') ? '&' : '?'}${queryParams}`;
357
+ }
358
+
326
359
  // Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
327
360
  const server = new McpServer(
328
361
  {
@@ -431,7 +464,7 @@ server.tool(
431
464
  routeTables: routeTableList,
432
465
  noRouteTables,
433
466
  canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
434
- customRouteWorkflow: 'For a new endpoint use create_route against an existing table, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
467
+ customRouteWorkflow: 'For a new endpoint use create_route without mainTableId, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
435
468
  },
436
469
  schemaManagement: {
437
470
  createTable: 'POST /table_definition supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly. It does not accept alias at create time; table name drives the default route/schema behavior.',
@@ -915,31 +948,65 @@ server.tool(
915
948
  // CRUD TOOLS
916
949
  // ============================================================================
917
950
 
918
- server.tool('create_record', 'Create a new record in any table', {
951
+ server.tool('create_record', 'Create a new record in any route-backed table. The tool validates body keys against live metadata and validates sourceCode before saving script-backed records.', {
919
952
  tableName: z.string().describe('Table name to insert into'),
920
953
  data: z.string().describe('Record data as JSON string'),
921
- }, async ({ tableName, data }) => {
954
+ queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
955
+ }, async ({ tableName, data, queryParams }) => {
922
956
  validateTableName(tableName);
923
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}`, { method: 'POST', body: data });
924
- return { content: [{ type: 'text', text: JSON.stringify(summarizeMutationResult(result, 'created', tableName), null, 2) }] };
957
+ const prepared = await prepareGenericMutation(tableName, data);
958
+ const query = parseQueryParamsArg(queryParams);
959
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
960
+ return { content: [{ type: 'text', text: JSON.stringify({
961
+ ...summarizeMutationResult(result, 'created', tableName),
962
+ scriptValidation: prepared.scriptValidation,
963
+ }, null, 2) }] };
925
964
  });
926
965
 
927
- server.tool('update_record', 'Update an existing record by ID using PATCH', {
966
+ server.tool('update_record', 'Update an existing record by ID using PATCH. The tool validates body keys against live metadata and validates sourceCode before saving script-backed records.', {
928
967
  tableName: z.string().describe('Table name'),
929
968
  id: z.string().describe('Record ID to update'),
930
969
  data: z.string().describe('Fields to update as JSON string'),
931
- }, async ({ tableName, id, data }) => {
970
+ queryParams: z.string().optional().describe('Optional query params as JSON object string for route contracts that intentionally keep workflow fields out of the validated body.'),
971
+ }, async ({ tableName, id, data, queryParams }) => {
932
972
  validateTableName(tableName);
933
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'PATCH', body: data });
934
- return { content: [{ type: 'text', text: JSON.stringify(summarizeMutationResult(result, 'updated', tableName), null, 2) }] };
973
+ const prepared = await prepareGenericMutation(tableName, data);
974
+ const query = parseQueryParamsArg(queryParams);
975
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
976
+ return { content: [{ type: 'text', text: JSON.stringify({
977
+ ...summarizeMutationResult(result, 'updated', tableName),
978
+ scriptValidation: prepared.scriptValidation,
979
+ }, null, 2) }] };
935
980
  });
936
981
 
937
982
  server.tool('delete_record', 'Delete a record by ID', {
938
983
  tableName: z.string().describe('Table name'),
939
984
  id: z.string().describe('Record ID to delete'),
940
- }, async ({ tableName, id }) => {
985
+ queryParams: z.string().optional().describe('Optional query params as JSON object string for route-specific confirmation contracts.'),
986
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
987
+ }, async ({ tableName, id, queryParams, confirm }) => {
941
988
  validateTableName(tableName);
942
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'DELETE' });
989
+ const primaryKey = await getPrimaryFieldName(tableName);
990
+ if (!confirm) {
991
+ const query = new URLSearchParams({
992
+ filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
993
+ limit: '1',
994
+ fields: primaryKey,
995
+ });
996
+ const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
997
+ return { content: [{ type: 'text', text: JSON.stringify({
998
+ action: 'delete_record_preview',
999
+ tableName,
1000
+ id,
1001
+ primaryKey,
1002
+ preview: preview?.data?.[0] || null,
1003
+ previewError: preview?.error,
1004
+ destructive: true,
1005
+ next: 'Call delete_record again with confirm=true to delete this route-backed record.',
1006
+ }, null, 2) }] };
1007
+ }
1008
+ const query = parseQueryParamsArg(queryParams);
1009
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'DELETE' });
943
1010
  return { content: [{ type: 'text', text: JSON.stringify({
944
1011
  action: 'deleted',
945
1012
  tableName,
@@ -1406,15 +1473,18 @@ server.tool(
1406
1473
  },
1407
1474
  async ({ path: routePath, mainTableId, methods, publishedMethods, isEnabled, description }) => {
1408
1475
  const methodMap = await getMethodMap();
1476
+ const normalizedPath = normalizeRestPath(routePath);
1409
1477
 
1410
1478
  const body = {
1411
- path: routePath.startsWith('/') ? routePath : '/' + routePath,
1479
+ path: normalizedPath,
1412
1480
  isEnabled,
1413
1481
  description,
1414
1482
  availableMethods: resolveMethodIds(methodMap, methods),
1415
1483
  };
1416
1484
 
1417
1485
  if (mainTableId !== undefined && mainTableId !== null) {
1486
+ const { tables } = await getMetadataTables();
1487
+ validateMainTableRoutePath(tables, mainTableId, normalizedPath);
1418
1488
  body.mainTable = { id: mainTableId };
1419
1489
  }
1420
1490
 
@@ -1470,6 +1540,10 @@ server.tool(
1470
1540
  if (methodNames.length === 0) throw new Error('Provide method or methods');
1471
1541
  const methodMap = await getMethodMap();
1472
1542
  const results = [];
1543
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'route_handler_definition', {
1544
+ sourceCode,
1545
+ scriptLanguage,
1546
+ });
1473
1547
 
1474
1548
  for (const methodName of methodNames) {
1475
1549
  const methodId = methodMap[methodName.toUpperCase()];
@@ -1497,6 +1571,7 @@ server.tool(
1497
1571
  return { content: [{ type: 'text', text: JSON.stringify({
1498
1572
  action: 'created',
1499
1573
  handlers: results,
1574
+ scriptValidation,
1500
1575
  routesReloaded: true,
1501
1576
  detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
1502
1577
  }, null, 2) }] };
@@ -1515,14 +1590,19 @@ server.tool(
1515
1590
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1516
1591
  name: z.string().describe('Hook name (unique per route)'),
1517
1592
  code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
1593
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
1518
1594
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1519
1595
  .describe('Methods this hook applies to. Default: all REST methods.'),
1520
1596
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
1521
1597
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
1522
1598
  },
1523
- async ({ routeId, name, code, methods, priority, isEnabled }) => {
1599
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
1524
1600
  const methodMap = await getMethodMap();
1525
1601
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
1602
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'pre_hook_definition', {
1603
+ sourceCode: code,
1604
+ scriptLanguage,
1605
+ });
1526
1606
 
1527
1607
  const result = await fetchAPI(ENFYRA_API_URL, '/pre_hook_definition', {
1528
1608
  method: 'POST',
@@ -1530,7 +1610,7 @@ server.tool(
1530
1610
  route: { id: routeId },
1531
1611
  name,
1532
1612
  sourceCode: code,
1533
- scriptLanguage: 'javascript',
1613
+ scriptLanguage,
1534
1614
  methods: resolveMethodIds(methodMap, methodNames),
1535
1615
  priority,
1536
1616
  isEnabled,
@@ -1540,7 +1620,15 @@ server.tool(
1540
1620
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1541
1621
 
1542
1622
  const created = firstDataRecord(result);
1543
- return { content: [{ type: 'text', text: `Pre-hook "${name}" created (ID: ${getId(created)}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1623
+ return { content: [{ type: 'text', text: JSON.stringify({
1624
+ action: 'created',
1625
+ kind: 'pre_hook',
1626
+ id: getId(created),
1627
+ name,
1628
+ routeId,
1629
+ scriptValidation,
1630
+ routesReloaded: true,
1631
+ }, null, 2) }] };
1544
1632
  },
1545
1633
  );
1546
1634
 
@@ -1556,14 +1644,19 @@ server.tool(
1556
1644
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1557
1645
  name: z.string().describe('Hook name (unique per route)'),
1558
1646
  code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
1647
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
1559
1648
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1560
1649
  .describe('Methods this hook applies to. Default: all REST methods.'),
1561
1650
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
1562
1651
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
1563
1652
  },
1564
- async ({ routeId, name, code, methods, priority, isEnabled }) => {
1653
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
1565
1654
  const methodMap = await getMethodMap();
1566
1655
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
1656
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'post_hook_definition', {
1657
+ sourceCode: code,
1658
+ scriptLanguage,
1659
+ });
1567
1660
 
1568
1661
  const result = await fetchAPI(ENFYRA_API_URL, '/post_hook_definition', {
1569
1662
  method: 'POST',
@@ -1571,7 +1664,7 @@ server.tool(
1571
1664
  route: { id: routeId },
1572
1665
  name,
1573
1666
  sourceCode: code,
1574
- scriptLanguage: 'javascript',
1667
+ scriptLanguage,
1575
1668
  methods: resolveMethodIds(methodMap, methodNames),
1576
1669
  priority,
1577
1670
  isEnabled,
@@ -1581,7 +1674,15 @@ server.tool(
1581
1674
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1582
1675
 
1583
1676
  const created = firstDataRecord(result);
1584
- return { content: [{ type: 'text', text: `Post-hook "${name}" created (ID: ${getId(created)}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1677
+ return { content: [{ type: 'text', text: JSON.stringify({
1678
+ action: 'created',
1679
+ kind: 'post_hook',
1680
+ id: getId(created),
1681
+ name,
1682
+ routeId,
1683
+ scriptValidation,
1684
+ routesReloaded: true,
1685
+ }, null, 2) }] };
1585
1686
  },
1586
1687
  );
1587
1688