@enfyra/mcp-server 0.0.55 → 0.0.56

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.56",
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
  /**
@@ -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)',
@@ -203,7 +206,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
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
208
  '- 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.',
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,6 +186,28 @@ async function verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
163
186
  return afterColumns;
164
187
  }
165
188
 
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
+
166
211
  function buildColumnDefinition({
167
212
  name,
168
213
  type,
@@ -196,6 +241,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
196
241
  const apiBase = ENFYRA_API_URL.replace(/\/$/, '');
197
242
 
198
243
  async function appendColumnToTable(args) {
244
+ return withSchemaQueue(async () => {
199
245
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, args.tableId);
200
246
  if (!tableData) {
201
247
  return { content: [{ type: 'text', text: `Error: Table with ID ${args.tableId} not found.` }] };
@@ -213,14 +259,17 @@ export function registerTableTools(server, ENFYRA_API_URL) {
213
259
  return {
214
260
  content: [{ type: 'text', text: `Column "${args.name}" added to table ${args.tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
215
261
  };
262
+ });
216
263
  }
217
264
 
218
265
  async function appendRelationToTable({ sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description }) {
266
+ return withSchemaQueue(async () => {
219
267
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, sourceTableId);
220
268
  if (!tableData) {
221
269
  return { content: [{ type: 'text', text: `Error: Table with ID ${sourceTableId} not found.` }] };
222
270
  }
223
271
  const existingRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
272
+ const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
224
273
  const newRelation = { targetTable: targetTableId, type, propertyName };
225
274
  if (inversePropertyName !== undefined) newRelation.inversePropertyName = inversePropertyName || null;
226
275
  if (mappedBy !== undefined) newRelation.mappedBy = mappedBy;
@@ -228,12 +277,18 @@ export function registerTableTools(server, ENFYRA_API_URL) {
228
277
  if (onDelete !== undefined) newRelation.onDelete = onDelete;
229
278
  if (description !== undefined) newRelation.description = description;
230
279
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, sourceTableId, { relations: [...existingRelations, newRelation] });
280
+ await verifyRelationCascade(ENFYRA_API_URL, sourceTableId, beforeIds, {
281
+ action: 'create',
282
+ propertyName,
283
+ });
231
284
  return {
232
285
  content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${sourceTableId} → ${targetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
233
286
  };
287
+ });
234
288
  }
235
289
 
236
- async function removeColumnFromTable({ tableId, columnId }) {
290
+ async function removeColumnFromTable({ tableId, columnId, confirm }) {
291
+ return withSchemaQueue(async () => {
237
292
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
238
293
  if (!tableData) {
239
294
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
@@ -244,6 +299,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
244
299
  if (!beforeIds.includes(String(columnId))) {
245
300
  throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
246
301
  }
302
+ if (!confirm) {
303
+ const target = existingColumns.find((column) => String(getId(column)) === String(columnId));
304
+ return {
305
+ content: [{ type: 'text', text: JSON.stringify({
306
+ action: 'delete_column_preview',
307
+ tableId,
308
+ columnId,
309
+ targetColumn: target,
310
+ preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
311
+ destructive: true,
312
+ next: 'Call delete_column/remove_column again with confirm=true to drop the physical column and metadata.',
313
+ }, null, 2) }],
314
+ };
315
+ }
247
316
 
248
317
  const columns = existingColumns
249
318
  .filter(col => String(getId(col)) !== String(columnId))
@@ -257,23 +326,49 @@ export function registerTableTools(server, ENFYRA_API_URL) {
257
326
  return {
258
327
  content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
259
328
  };
329
+ });
260
330
  }
261
331
 
262
- async function removeRelationFromTable({ tableId, relationId }) {
332
+ async function removeRelationFromTable({ tableId, relationId, confirm }) {
333
+ return withSchemaQueue(async () => {
263
334
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
264
335
  if (!tableData) {
265
336
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
266
337
  }
267
338
 
268
- const relations = (tableData.relations || [])
269
- .filter(rel => String(rel.id) !== String(relationId))
270
- .map(normalizeRelationForTablePatch);
339
+ const existingRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
340
+ const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
341
+ if (!beforeIds.includes(String(relationId))) {
342
+ throw new Error(`Relation ${relationId} was not found on table ${tableId}; refusing schema cascade patch.`);
343
+ }
344
+ if (!confirm) {
345
+ const target = existingRelations.find((relation) => String(getId(relation)) === String(relationId));
346
+ return {
347
+ content: [{ type: 'text', text: JSON.stringify({
348
+ action: 'delete_relation_preview',
349
+ tableId,
350
+ relationId,
351
+ targetRelation: target,
352
+ preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
353
+ destructive: true,
354
+ next: 'Call delete_relation/remove_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
355
+ }, null, 2) }],
356
+ };
357
+ }
358
+
359
+ const relations = existingRelations
360
+ .filter(rel => String(getId(rel)) !== String(relationId))
271
361
 
272
362
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { relations });
363
+ await verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
364
+ action: 'delete',
365
+ relationId,
366
+ });
273
367
 
274
368
  return {
275
369
  content: [{ type: 'text', text: `Relation ${relationId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
276
370
  };
371
+ });
277
372
  }
278
373
 
279
374
  const columnCreateSchema = {
@@ -306,11 +401,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
306
401
  const columnDeleteSchema = {
307
402
  tableId: z.string().describe('Table definition ID.'),
308
403
  columnId: z.string().describe('Column definition ID to delete.'),
404
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
309
405
  };
310
406
 
311
407
  const relationDeleteSchema = {
312
408
  tableId: z.string().describe('Table definition ID (source table of the relation).'),
313
409
  relationId: z.string().describe('Relation 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.'),
314
411
  };
315
412
 
316
413
  // ─── READ ───
@@ -356,7 +453,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
356
453
  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
454
  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
455
  },
359
- async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
456
+ async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
360
457
  const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
361
458
  const userColumns = parseJsonArrayParam('columns', columnsJson);
362
459
  const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
@@ -391,7 +488,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
391
488
  return {
392
489
  content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
393
490
  };
394
- }
491
+ })
395
492
  );
396
493
 
397
494
  // ─── UPDATE TABLE ───
@@ -414,7 +511,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
414
511
  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
512
  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
513
  },
417
- async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => {
514
+ async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
418
515
  const body = {};
419
516
  if (name !== undefined) body.name = name;
420
517
  if (alias !== undefined) body.alias = alias;
@@ -428,7 +525,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
428
525
  return {
429
526
  content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
430
527
  };
431
- }
528
+ })
432
529
  );
433
530
 
434
531
  // ─── DELETE TABLE ───
@@ -442,15 +539,30 @@ export function registerTableTools(server, ENFYRA_API_URL) {
442
539
  ].join(' '),
443
540
  {
444
541
  tableId: z.string().describe('Table definition ID to delete.'),
542
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
445
543
  },
446
- async ({ tableId }) => {
544
+ async ({ tableId, confirm }) => withSchemaQueue(async () => {
545
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
546
+ if (!confirm) {
547
+ return {
548
+ content: [{ type: 'text', text: JSON.stringify({
549
+ action: 'delete_table_preview',
550
+ tableId,
551
+ tableName: tableData.name,
552
+ columnCount: (tableData.columns || []).length,
553
+ relationCount: (tableData.relations || []).length,
554
+ destructive: true,
555
+ next: 'Call delete_table again with confirm=true to delete metadata, routes, derived FK/junction structures, the physical table, and all table data.',
556
+ }, null, 2) }],
557
+ };
558
+ }
447
559
  const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
448
560
  method: 'DELETE',
449
561
  });
450
562
  return {
451
563
  content: [{ type: 'text', text: `Table ${tableId} deleted.\n\n${JSON.stringify(result, null, 2)}` }],
452
564
  };
453
- }
565
+ })
454
566
  );
455
567
 
456
568
  // ─── CREATE COLUMN ───
@@ -503,7 +615,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
503
615
  description: z.string().optional().describe('New description.'),
504
616
  options: z.string().optional().describe('New options as JSON string.'),
505
617
  },
506
- async ({ tableId, columnId, name, type, isNullable, isPublished, defaultValue, description, options }) => {
618
+ async ({ tableId, columnId, name, type, isNullable, isPublished, defaultValue, description, options }) => withSchemaQueue(async () => {
507
619
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
508
620
  if (!tableData) {
509
621
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
@@ -538,7 +650,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
538
650
  return {
539
651
  content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
540
652
  };
541
- }
653
+ })
542
654
  );
543
655
 
544
656
  // ─── 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);
@@ -323,6 +325,35 @@ function resolveFieldOrThrow(table, fieldName, kind = 'column') {
323
325
  return field;
324
326
  }
325
327
 
328
+ async function prepareGenericMutation(tableName, data) {
329
+ const { tables } = await getMetadataTables();
330
+ return prepareRecordMutation({
331
+ fetchAPI,
332
+ apiUrl: ENFYRA_API_URL,
333
+ tables,
334
+ tableName,
335
+ data,
336
+ });
337
+ }
338
+
339
+ function parseQueryParamsArg(queryParams) {
340
+ const parsed = parseJsonArg(queryParams, {});
341
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
342
+ throw new Error('queryParams must be a JSON object string.');
343
+ }
344
+ const params = new URLSearchParams();
345
+ for (const [key, value] of Object.entries(parsed)) {
346
+ if (value === undefined || value === null) continue;
347
+ params.set(key, String(value));
348
+ }
349
+ return params.toString();
350
+ }
351
+
352
+ function appendQuery(path, queryParams) {
353
+ if (!queryParams) return path;
354
+ return `${path}${path.includes('?') ? '&' : '?'}${queryParams}`;
355
+ }
356
+
326
357
  // Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
327
358
  const server = new McpServer(
328
359
  {
@@ -431,7 +462,7 @@ server.tool(
431
462
  routeTables: routeTableList,
432
463
  noRouteTables,
433
464
  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.',
465
+ 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
466
  },
436
467
  schemaManagement: {
437
468
  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 +946,65 @@ server.tool(
915
946
  // CRUD TOOLS
916
947
  // ============================================================================
917
948
 
918
- server.tool('create_record', 'Create a new record in any table', {
949
+ 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
950
  tableName: z.string().describe('Table name to insert into'),
920
951
  data: z.string().describe('Record data as JSON string'),
921
- }, async ({ tableName, data }) => {
952
+ 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.'),
953
+ }, async ({ tableName, data, queryParams }) => {
922
954
  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) }] };
955
+ const prepared = await prepareGenericMutation(tableName, data);
956
+ const query = parseQueryParamsArg(queryParams);
957
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
958
+ return { content: [{ type: 'text', text: JSON.stringify({
959
+ ...summarizeMutationResult(result, 'created', tableName),
960
+ scriptValidation: prepared.scriptValidation,
961
+ }, null, 2) }] };
925
962
  });
926
963
 
927
- server.tool('update_record', 'Update an existing record by ID using PATCH', {
964
+ 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
965
  tableName: z.string().describe('Table name'),
929
966
  id: z.string().describe('Record ID to update'),
930
967
  data: z.string().describe('Fields to update as JSON string'),
931
- }, async ({ tableName, id, data }) => {
968
+ 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.'),
969
+ }, async ({ tableName, id, data, queryParams }) => {
932
970
  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) }] };
971
+ const prepared = await prepareGenericMutation(tableName, data);
972
+ const query = parseQueryParamsArg(queryParams);
973
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
974
+ return { content: [{ type: 'text', text: JSON.stringify({
975
+ ...summarizeMutationResult(result, 'updated', tableName),
976
+ scriptValidation: prepared.scriptValidation,
977
+ }, null, 2) }] };
935
978
  });
936
979
 
937
980
  server.tool('delete_record', 'Delete a record by ID', {
938
981
  tableName: z.string().describe('Table name'),
939
982
  id: z.string().describe('Record ID to delete'),
940
- }, async ({ tableName, id }) => {
983
+ queryParams: z.string().optional().describe('Optional query params as JSON object string for route-specific confirmation contracts.'),
984
+ confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
985
+ }, async ({ tableName, id, queryParams, confirm }) => {
941
986
  validateTableName(tableName);
942
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'DELETE' });
987
+ const primaryKey = await getPrimaryFieldName(tableName);
988
+ if (!confirm) {
989
+ const query = new URLSearchParams({
990
+ filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
991
+ limit: '1',
992
+ fields: primaryKey,
993
+ });
994
+ const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
995
+ return { content: [{ type: 'text', text: JSON.stringify({
996
+ action: 'delete_record_preview',
997
+ tableName,
998
+ id,
999
+ primaryKey,
1000
+ preview: preview?.data?.[0] || null,
1001
+ previewError: preview?.error,
1002
+ destructive: true,
1003
+ next: 'Call delete_record again with confirm=true to delete this route-backed record.',
1004
+ }, null, 2) }] };
1005
+ }
1006
+ const query = parseQueryParamsArg(queryParams);
1007
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'DELETE' });
943
1008
  return { content: [{ type: 'text', text: JSON.stringify({
944
1009
  action: 'deleted',
945
1010
  tableName,
@@ -1406,15 +1471,18 @@ server.tool(
1406
1471
  },
1407
1472
  async ({ path: routePath, mainTableId, methods, publishedMethods, isEnabled, description }) => {
1408
1473
  const methodMap = await getMethodMap();
1474
+ const normalizedPath = normalizeRestPath(routePath);
1409
1475
 
1410
1476
  const body = {
1411
- path: routePath.startsWith('/') ? routePath : '/' + routePath,
1477
+ path: normalizedPath,
1412
1478
  isEnabled,
1413
1479
  description,
1414
1480
  availableMethods: resolveMethodIds(methodMap, methods),
1415
1481
  };
1416
1482
 
1417
1483
  if (mainTableId !== undefined && mainTableId !== null) {
1484
+ const { tables } = await getMetadataTables();
1485
+ validateMainTableRoutePath(tables, mainTableId, normalizedPath);
1418
1486
  body.mainTable = { id: mainTableId };
1419
1487
  }
1420
1488
 
@@ -1470,6 +1538,10 @@ server.tool(
1470
1538
  if (methodNames.length === 0) throw new Error('Provide method or methods');
1471
1539
  const methodMap = await getMethodMap();
1472
1540
  const results = [];
1541
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'route_handler_definition', {
1542
+ sourceCode,
1543
+ scriptLanguage,
1544
+ });
1473
1545
 
1474
1546
  for (const methodName of methodNames) {
1475
1547
  const methodId = methodMap[methodName.toUpperCase()];
@@ -1497,6 +1569,7 @@ server.tool(
1497
1569
  return { content: [{ type: 'text', text: JSON.stringify({
1498
1570
  action: 'created',
1499
1571
  handlers: results,
1572
+ scriptValidation,
1500
1573
  routesReloaded: true,
1501
1574
  detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
1502
1575
  }, null, 2) }] };
@@ -1515,14 +1588,19 @@ server.tool(
1515
1588
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1516
1589
  name: z.string().describe('Hook name (unique per route)'),
1517
1590
  code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
1591
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
1518
1592
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1519
1593
  .describe('Methods this hook applies to. Default: all REST methods.'),
1520
1594
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
1521
1595
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
1522
1596
  },
1523
- async ({ routeId, name, code, methods, priority, isEnabled }) => {
1597
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
1524
1598
  const methodMap = await getMethodMap();
1525
1599
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
1600
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'pre_hook_definition', {
1601
+ sourceCode: code,
1602
+ scriptLanguage,
1603
+ });
1526
1604
 
1527
1605
  const result = await fetchAPI(ENFYRA_API_URL, '/pre_hook_definition', {
1528
1606
  method: 'POST',
@@ -1530,7 +1608,7 @@ server.tool(
1530
1608
  route: { id: routeId },
1531
1609
  name,
1532
1610
  sourceCode: code,
1533
- scriptLanguage: 'javascript',
1611
+ scriptLanguage,
1534
1612
  methods: resolveMethodIds(methodMap, methodNames),
1535
1613
  priority,
1536
1614
  isEnabled,
@@ -1540,7 +1618,15 @@ server.tool(
1540
1618
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1541
1619
 
1542
1620
  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)}` }] };
1621
+ return { content: [{ type: 'text', text: JSON.stringify({
1622
+ action: 'created',
1623
+ kind: 'pre_hook',
1624
+ id: getId(created),
1625
+ name,
1626
+ routeId,
1627
+ scriptValidation,
1628
+ routesReloaded: true,
1629
+ }, null, 2) }] };
1544
1630
  },
1545
1631
  );
1546
1632
 
@@ -1556,14 +1642,19 @@ server.tool(
1556
1642
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1557
1643
  name: z.string().describe('Hook name (unique per route)'),
1558
1644
  code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
1645
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
1559
1646
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1560
1647
  .describe('Methods this hook applies to. Default: all REST methods.'),
1561
1648
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
1562
1649
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
1563
1650
  },
1564
- async ({ routeId, name, code, methods, priority, isEnabled }) => {
1651
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
1565
1652
  const methodMap = await getMethodMap();
1566
1653
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
1654
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'post_hook_definition', {
1655
+ sourceCode: code,
1656
+ scriptLanguage,
1657
+ });
1567
1658
 
1568
1659
  const result = await fetchAPI(ENFYRA_API_URL, '/post_hook_definition', {
1569
1660
  method: 'POST',
@@ -1571,7 +1662,7 @@ server.tool(
1571
1662
  route: { id: routeId },
1572
1663
  name,
1573
1664
  sourceCode: code,
1574
- scriptLanguage: 'javascript',
1665
+ scriptLanguage,
1575
1666
  methods: resolveMethodIds(methodMap, methodNames),
1576
1667
  priority,
1577
1668
  isEnabled,
@@ -1581,7 +1672,15 @@ server.tool(
1581
1672
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1582
1673
 
1583
1674
  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)}` }] };
1675
+ return { content: [{ type: 'text', text: JSON.stringify({
1676
+ action: 'created',
1677
+ kind: 'post_hook',
1678
+ id: getId(created),
1679
+ name,
1680
+ routeId,
1681
+ scriptValidation,
1682
+ routesReloaded: true,
1683
+ }, null, 2) }] };
1585
1684
  },
1586
1685
  );
1587
1686