@enfyra/mcp-server 0.0.54 → 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 +6 -0
- package/package.json +1 -1
- package/src/lib/auth.js +4 -0
- package/src/lib/fetch.js +44 -36
- package/src/lib/mcp-instructions.js +11 -7
- package/src/lib/mutation-guards.js +118 -0
- package/src/lib/route-guards.js +24 -0
- package/src/lib/table-tools.js +126 -14
- package/src/mcp-server-entry.mjs +117 -18
package/README.md
CHANGED
|
@@ -182,6 +182,12 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
|
|
|
182
182
|
| `ENFYRA_API_URL` | Base for REST + GraphQL + auth through the Nuxt/app proxy | `http://localhost:3000/api` |
|
|
183
183
|
| `ENFYRA_API_TOKEN` | Programmatic token from eApp `/me`. MCP exchanges it through `/auth/token/exchange` for an access token. | — |
|
|
184
184
|
|
|
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
|
+
|
|
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
|
+
|
|
185
191
|
### `ENFYRA_API_URL` — use the app proxy
|
|
186
192
|
|
|
187
193
|
For normal apps and demos, set `ENFYRA_API_URL` to the Nuxt/app proxy:
|
package/package.json
CHANGED
package/src/lib/auth.js
CHANGED
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();
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
/**
|
|
@@ -66,7 +66,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
66
66
|
'- OAuth starts on the same proxy prefix, e.g. **`GET /enfyra/auth/{provider}?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`**. `redirect` must be an absolute `http(s)` URL with the app origin. `cookieBridgePrefix` is the third app proxy prefix that forwards to the Enfyra API; Enfyra normalizes it, so `enfyra`, `/enfyra`, and `/enfyra/` all mean `/enfyra`. Use token-query callback handling only when the app intentionally manages tokens itself.',
|
|
67
67
|
'- Socket.IO uses the app bridge too. Browser clients should connect to the gateway namespace with the Socket.IO transport path on the app origin, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, while Nuxt proxies `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. Do not connect browser code directly to the hidden backend Socket.IO endpoint.',
|
|
68
68
|
'- If a project explicitly standardizes on `/api/**` instead of `/enfyra/**`, keep the same Cloud-style behavior under that prefix: proxy to the Enfyra API and avoid generated cookie-management routes unless the user asks for a custom auth boundary.',
|
|
69
|
-
'- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server exchanges `ENFYRA_API_TOKEN` against `{ENFYRA_API_URL}/auth/token/exchange
|
|
69
|
+
'- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server exchanges `ENFYRA_API_TOKEN` against `{ENFYRA_API_URL}/auth/token/exchange` before authenticated tool calls. The raw `efy_pat_*` token is never a Bearer token. For normal app work, `ENFYRA_API_URL` must still be the app proxy base such as `{{ nuxtApp }}/api`.',
|
|
70
70
|
'',
|
|
71
71
|
'### Routes vs tables (custom endpoints, handlers, hooks)',
|
|
72
72
|
'- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
|
|
@@ -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 }`,
|
|
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
|
|
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
|
+
}
|
package/src/lib/table-tools.js
CHANGED
|
@@ -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
|
|
269
|
-
|
|
270
|
-
|
|
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 ───
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
924
|
-
|
|
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
|
-
|
|
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
|
|
934
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|