@enfyra/mcp-server 0.0.55 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/lib/auth.js +4 -0
- package/src/lib/fetch.js +44 -36
- package/src/lib/mcp-examples.js +11 -0
- package/src/lib/mcp-instructions.js +12 -8
- package/src/lib/mutation-guards.js +118 -0
- package/src/lib/route-guards.js +24 -0
- package/src/lib/table-tools.js +136 -16
- package/src/mcp-server-entry.mjs +119 -18
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
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(apiUrl);
|
|
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
|
/**
|
package/src/lib/mcp-examples.js
CHANGED
|
@@ -203,10 +203,21 @@ create_column({
|
|
|
203
203
|
defaultValue: "pending",
|
|
204
204
|
isPublished: true,
|
|
205
205
|
description: "Email verification state controlled by server hooks."
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
create_column({
|
|
209
|
+
tableId: "<project_env_table_id>",
|
|
210
|
+
name: "value",
|
|
211
|
+
type: "text",
|
|
212
|
+
isNullable: false,
|
|
213
|
+
isPublished: false,
|
|
214
|
+
isEncrypted: true,
|
|
215
|
+
description: "Encrypted environment value."
|
|
206
216
|
})`,
|
|
207
217
|
notes: [
|
|
208
218
|
'Run schema-changing calls sequentially. Do not parallelize create_column calls.',
|
|
209
219
|
'create_column fetches table_definition and patches only real persisted columns with id/_id; generated metadata projections such as createdAt, updatedAt, or relation FK display fields are skipped.',
|
|
220
|
+
'Use isEncrypted=true for encryption at rest. Add isUpdatable=false separately only when the field should be immutable.',
|
|
210
221
|
'Use hooks or field permissions to prevent clients from updating server-owned fields.',
|
|
211
222
|
],
|
|
212
223
|
},
|
|
@@ -107,6 +107,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
107
107
|
'',
|
|
108
108
|
'### Relation field format (create_record / update_record)',
|
|
109
109
|
'- For generic MCP `create_record` and `update_record`, the `data` argument is a **JSON string**, not a JavaScript object. Example: `data: "{\\"name\\":\\"Starter\\"}"`. If the host gives a validation error saying `data` expected string, stringify the object before calling the tool.',
|
|
110
|
+
'- Generic MCP `create_record` and `update_record` validate body keys against live metadata before sending REST. If a field such as `expiredAt` is not in metadata, do not bypass body validation; add the metadata field through schema tools or pass route workflow fields in the tool `queryParams` JSON when that route explicitly owns a query contract such as `{"expired_at":"2026-09-20"}`.',
|
|
111
|
+
'- Generic MCP script-table mutations reject `compiledCode`, reject legacy `code` aliases, and must validate `sourceCode` with `/admin/script/validate` before saving. If validation is unavailable or fails, the save must fail closed. Prefer dedicated `create_handler`, `create_pre_hook`, and `create_post_hook` tools for route code.',
|
|
112
|
+
'- Generic MCP `delete_record` is destructive but preview-first: the first call without `confirm=true` returns the target preview; call it again with `confirm=true` only after the user explicitly approves deletion. Use `queryParams` for route-specific confirmation contracts instead of putting those fields in the body.',
|
|
110
113
|
'- Relation fields (publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
|
|
111
114
|
'- **mainTable warning:** do not set `mainTable` on custom routes. It is reserved for canonical table routes only.',
|
|
112
115
|
' - **Many-to-one:** `"someRelation": {"id": 4}` (single object with id)',
|
|
@@ -136,7 +139,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
136
139
|
'### Dynamic script syntax preference',
|
|
137
140
|
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.',
|
|
138
141
|
'- Use Enfyra native throw helpers for intentional errors: `@THROW400("message")`, `@THROW403()`, `@THROW404("resource", id)`, or `$ctx.$throw[400]("message")`. Do not generate `throw new Error(...)` for user/domain errors in handlers, hooks, flows, websocket events, OAuth scripts, or admin-generated scripts.',
|
|
139
|
-
'- For encrypted
|
|
142
|
+
'- For regular app data that must be encrypted at rest, create the column with `isEncrypted=true`; Enfyra database-query hooks will encrypt on insert/update and decrypt after select. `isEncrypted` does not imply immutability; use `isUpdatable=false` separately only when the field itself must be immutable. Do not filter or sort on encrypted fields. Legacy Cloud control-plane fields named `*_encrypted` may still use route pre-hooks until migrated.',
|
|
140
143
|
'- ASV exposes `$helpers.$encrypt.encrypt/decrypt` for encrypted strings and `$helpers.$ssh.generateKeyPair` for SSH keys. Do not generate `$helpers.$secrets` usage.',
|
|
141
144
|
'- Script-backed records use one shared persistence contract: `sourceCode` is the editable source, `scriptLanguage` controls compilation, and `compiledCode` is generated by the server from `sourceCode`. Do not hand-edit or send stale `compiledCode` from generated tools; save `sourceCode`/`scriptLanguage` through `PATCH /<script_table>/<id>` and let the server persist generated `compiledCode` internally. Public metadata may mark `compiledCode` non-updatable, but the server engine must still preserve the generated value after normalization.',
|
|
142
145
|
'- For route handlers specifically, the field is also `sourceCode`. Older names such as `logic` are wrong for current Enfyra REST CRUD and will be rejected. Use MCP `create_handler` so it writes `sourceCode` and resolves method ids correctly.',
|
|
@@ -202,8 +205,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
202
205
|
'- To check which tables have canonical CRUD routes, call `get_all_routes` and look for `mainTable`. Custom routes intentionally have no `mainTable`; inspect their handlers/hooks to see which repos they touch.',
|
|
203
206
|
'- **Tables confirmed to have REST routes (system):** `bootstrap_script_definition`, `column_rule_definition`, `cors_origin_definition`, `extension_definition`, `field_permission_definition`, `file_definition`, `file_permission_definition`, `flow_definition`, `flow_execution_definition`, `flow_step_definition`, `folder_definition`, `gql_definition`, `guard_definition`, `guard_rule_definition`, `menu_definition`, `method_definition`, `oauth_account_definition`, `oauth_config_definition`, `package_definition`, `post_hook_definition`, `pre_hook_definition`, `relation_definition`, `role_definition`, `route_definition`, `route_handler_definition`, `route_permission_definition`, `schema_migration_definition`, `setting_definition`, `storage_config_definition`, `table_definition`, `user_definition`, `websocket_definition`, `websocket_event_definition`.',
|
|
204
207
|
'- **Tables without REST routes (internal/system only):** `column_definition`, `session_definition`. Columns are managed indirectly via cascade on `table_definition` (POST/PATCH with columns arrays). The `create_table`, `create_column`/`add_column`, `update_column`, and `delete_column`/`remove_column` MCP tools handle this automatically by reading full table metadata first.',
|
|
205
|
-
'- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isPublished=false` directly when creating secret/internal fields
|
|
206
|
-
'- Prefer `create_relation`/`add_relation` and `delete_relation`/`remove_relation` for relation schema changes because they preserve the full table relation list and handle schema-confirm retry. Direct `create_record` on `relation_definition` only edits metadata and is not the canonical schema migration path
|
|
208
|
+
'- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isUpdatable`, `isEncrypted`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isUpdatable=false` for immutable fields and set `isPublished=false` directly when creating secret/internal fields. `isEncrypted=true` encrypts stored values at rest but does not change `isUpdatable`; encrypted fields must not be used for filter/sort. When patching an existing table, only persisted columns with an `id`/`_id` belong in the cascade payload; metadata projections such as `createdAt`, `updatedAt`, or relation-derived FK display fields without an id are not valid column-definition patch rows. Never rebuild a schema cascade from `table_definition?fields=columns.*`, because nested relation fields may be paginated/truncated.',
|
|
209
|
+
'- Prefer `create_relation`/`add_relation` and `delete_relation`/`remove_relation` for relation schema changes because they preserve the full table relation list, serialize schema mutations, verify unrelated relation ids survived, and handle schema-confirm retry. Direct `create_record` on `relation_definition` only edits metadata and is not the canonical schema migration path; generic record mutations also reject physical FK/junction fields on `relation_definition`.',
|
|
210
|
+
'- Destructive schema tools and generic `delete_record` return a preview unless `confirm=true` is passed. This applies to `delete_record`, `delete_table`, `delete_column`/`remove_column`, and `delete_relation`/`remove_relation`; do not add `confirm=true` until the user has explicitly approved the destructive operation.',
|
|
207
211
|
'',
|
|
208
212
|
'### Body validation & column rules',
|
|
209
213
|
'- Each `table_definition` has a **`validateBody`** flag (default `true` for new tables). When on, every `POST /<table>` and `PATCH /<table>/<id>` is validated server-side against the column types + any **column rules** attached to columns of that table.',
|
|
@@ -310,9 +314,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
310
314
|
'- **Admin menu visibility is permission-driven, not RLS:** Cloud/admin menu entries are sensitive and must set `menu_definition.permission` so they are visible only to users who have at least GET permission for the backing route or table. Permission conditions use HTTP `methods`, not CRUD `actions`. Do not show a Cloud menu merely because an extension exists or because the path is hardcoded. Example: `/cloud/hosts` menu should require `{ or: [{ route: "/cloud/admin/hosts", methods: ["GET"] }, { route: "/cloud_servers", methods: ["GET"] }] }`.',
|
|
311
315
|
'- **PermissionGate is mandatory inside admin extensions:** every sensitive action button, form, mutation, destructive workflow, and data shortcut must be wrapped in `PermissionGate` or guarded with `usePermissions()` before rendering/enabling. Default gates: list/detail visibility needs `methods: ["GET"]`; create and custom flow-trigger routes usually need `methods: ["POST"]`; native record edits need `methods: ["PATCH"]`; native delete routes need `methods: ["DELETE"]`. Root admin still passes through normal permission helpers, but extension code must not rely on root-only assumptions.',
|
|
312
316
|
'- **Extension permission UX:** if the current user can read a page but cannot perform an action, hide the action by default. If hiding would confuse the workflow, render a disabled state with a short reason. Never let the button render active and depend only on the server rejection; server permissions are the final boundary, not the UI contract.',
|
|
313
|
-
'- **Cloud project admin operations:** use canonical `cloud_projects` table routes as the single source of truth. Admin manual create uses `POST /cloud_projects` with schema-safe fields `owner: { id }`, `plan: { id }`,
|
|
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,12 +186,36 @@ async function verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
|
163
186
|
return afterColumns;
|
|
164
187
|
}
|
|
165
188
|
|
|
166
|
-
function
|
|
189
|
+
async function verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
190
|
+
action,
|
|
191
|
+
relationId,
|
|
192
|
+
propertyName,
|
|
193
|
+
}) {
|
|
194
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
195
|
+
const afterRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
|
|
196
|
+
const afterIds = afterRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
197
|
+
const excludedIds = action === 'delete' ? [relationId] : [];
|
|
198
|
+
const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
|
|
199
|
+
if (missingIds.length > 0) {
|
|
200
|
+
throw new Error(`Schema cascade verification failed: unrelated relation ids disappeared: ${missingIds.join(', ')}`);
|
|
201
|
+
}
|
|
202
|
+
if (action === 'create' && !afterRelations.some((relation) => relation.propertyName === propertyName)) {
|
|
203
|
+
throw new Error(`Schema cascade verification failed: relation "${propertyName}" was not found after create.`);
|
|
204
|
+
}
|
|
205
|
+
if (action === 'delete' && afterIds.includes(String(relationId))) {
|
|
206
|
+
throw new Error(`Schema cascade verification failed: relation ${relationId} still exists after delete.`);
|
|
207
|
+
}
|
|
208
|
+
return afterRelations;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildColumnDefinition({
|
|
167
212
|
name,
|
|
168
213
|
type,
|
|
169
214
|
isNullable,
|
|
170
215
|
isUnique,
|
|
171
216
|
isPublished,
|
|
217
|
+
isUpdatable,
|
|
218
|
+
isEncrypted,
|
|
172
219
|
isPrimary,
|
|
173
220
|
isGenerated,
|
|
174
221
|
isSystem,
|
|
@@ -180,6 +227,8 @@ function buildColumnDefinition({
|
|
|
180
227
|
if (isNullable !== undefined) column.isNullable = isNullable;
|
|
181
228
|
if (isUnique !== undefined) column.isUnique = isUnique;
|
|
182
229
|
if (isPublished !== undefined) column.isPublished = isPublished;
|
|
230
|
+
if (isUpdatable !== undefined) column.isUpdatable = isUpdatable;
|
|
231
|
+
if (isEncrypted !== undefined) column.isEncrypted = isEncrypted;
|
|
183
232
|
if (isPrimary !== undefined) column.isPrimary = isPrimary;
|
|
184
233
|
if (isGenerated !== undefined) column.isGenerated = isGenerated;
|
|
185
234
|
if (isSystem !== undefined) column.isSystem = isSystem;
|
|
@@ -196,6 +245,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
196
245
|
const apiBase = ENFYRA_API_URL.replace(/\/$/, '');
|
|
197
246
|
|
|
198
247
|
async function appendColumnToTable(args) {
|
|
248
|
+
return withSchemaQueue(async () => {
|
|
199
249
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, args.tableId);
|
|
200
250
|
if (!tableData) {
|
|
201
251
|
return { content: [{ type: 'text', text: `Error: Table with ID ${args.tableId} not found.` }] };
|
|
@@ -213,14 +263,17 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
213
263
|
return {
|
|
214
264
|
content: [{ type: 'text', text: `Column "${args.name}" added to table ${args.tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
215
265
|
};
|
|
266
|
+
});
|
|
216
267
|
}
|
|
217
268
|
|
|
218
269
|
async function appendRelationToTable({ sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description }) {
|
|
270
|
+
return withSchemaQueue(async () => {
|
|
219
271
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, sourceTableId);
|
|
220
272
|
if (!tableData) {
|
|
221
273
|
return { content: [{ type: 'text', text: `Error: Table with ID ${sourceTableId} not found.` }] };
|
|
222
274
|
}
|
|
223
275
|
const existingRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
|
|
276
|
+
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
224
277
|
const newRelation = { targetTable: targetTableId, type, propertyName };
|
|
225
278
|
if (inversePropertyName !== undefined) newRelation.inversePropertyName = inversePropertyName || null;
|
|
226
279
|
if (mappedBy !== undefined) newRelation.mappedBy = mappedBy;
|
|
@@ -228,12 +281,18 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
228
281
|
if (onDelete !== undefined) newRelation.onDelete = onDelete;
|
|
229
282
|
if (description !== undefined) newRelation.description = description;
|
|
230
283
|
const result = await patchTableAutoConfirm(ENFYRA_API_URL, sourceTableId, { relations: [...existingRelations, newRelation] });
|
|
284
|
+
await verifyRelationCascade(ENFYRA_API_URL, sourceTableId, beforeIds, {
|
|
285
|
+
action: 'create',
|
|
286
|
+
propertyName,
|
|
287
|
+
});
|
|
231
288
|
return {
|
|
232
289
|
content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${sourceTableId} → ${targetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
233
290
|
};
|
|
291
|
+
});
|
|
234
292
|
}
|
|
235
293
|
|
|
236
|
-
async function removeColumnFromTable({ tableId, columnId }) {
|
|
294
|
+
async function removeColumnFromTable({ tableId, columnId, confirm }) {
|
|
295
|
+
return withSchemaQueue(async () => {
|
|
237
296
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
238
297
|
if (!tableData) {
|
|
239
298
|
return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
|
|
@@ -244,6 +303,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
244
303
|
if (!beforeIds.includes(String(columnId))) {
|
|
245
304
|
throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
246
305
|
}
|
|
306
|
+
if (!confirm) {
|
|
307
|
+
const target = existingColumns.find((column) => String(getId(column)) === String(columnId));
|
|
308
|
+
return {
|
|
309
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
310
|
+
action: 'delete_column_preview',
|
|
311
|
+
tableId,
|
|
312
|
+
columnId,
|
|
313
|
+
targetColumn: target,
|
|
314
|
+
preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
|
|
315
|
+
destructive: true,
|
|
316
|
+
next: 'Call delete_column/remove_column again with confirm=true to drop the physical column and metadata.',
|
|
317
|
+
}, null, 2) }],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
247
320
|
|
|
248
321
|
const columns = existingColumns
|
|
249
322
|
.filter(col => String(getId(col)) !== String(columnId))
|
|
@@ -257,23 +330,49 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
257
330
|
return {
|
|
258
331
|
content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
259
332
|
};
|
|
333
|
+
});
|
|
260
334
|
}
|
|
261
335
|
|
|
262
|
-
async function removeRelationFromTable({ tableId, relationId }) {
|
|
336
|
+
async function removeRelationFromTable({ tableId, relationId, confirm }) {
|
|
337
|
+
return withSchemaQueue(async () => {
|
|
263
338
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
264
339
|
if (!tableData) {
|
|
265
340
|
return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
|
|
266
341
|
}
|
|
267
342
|
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
343
|
+
const existingRelations = (tableData.relations || []).map(normalizeRelationForTablePatch);
|
|
344
|
+
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
345
|
+
if (!beforeIds.includes(String(relationId))) {
|
|
346
|
+
throw new Error(`Relation ${relationId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
347
|
+
}
|
|
348
|
+
if (!confirm) {
|
|
349
|
+
const target = existingRelations.find((relation) => String(getId(relation)) === String(relationId));
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
352
|
+
action: 'delete_relation_preview',
|
|
353
|
+
tableId,
|
|
354
|
+
relationId,
|
|
355
|
+
targetRelation: target,
|
|
356
|
+
preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
|
|
357
|
+
destructive: true,
|
|
358
|
+
next: 'Call delete_relation/remove_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
|
|
359
|
+
}, null, 2) }],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const relations = existingRelations
|
|
364
|
+
.filter(rel => String(getId(rel)) !== String(relationId))
|
|
271
365
|
|
|
272
366
|
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { relations });
|
|
367
|
+
await verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
368
|
+
action: 'delete',
|
|
369
|
+
relationId,
|
|
370
|
+
});
|
|
273
371
|
|
|
274
372
|
return {
|
|
275
373
|
content: [{ type: 'text', text: `Relation ${relationId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
276
374
|
};
|
|
375
|
+
});
|
|
277
376
|
}
|
|
278
377
|
|
|
279
378
|
const columnCreateSchema = {
|
|
@@ -283,6 +382,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
283
382
|
isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
|
|
284
383
|
isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
|
|
285
384
|
isPublished: z.boolean().optional().describe('Set column visibility baseline. Use false for secrets and internal fields.'),
|
|
385
|
+
isUpdatable: z.boolean().optional().describe('Set false for immutable fields that cannot be updated after creation. Independent from isEncrypted.'),
|
|
386
|
+
isEncrypted: z.boolean().optional().describe('Set true to encrypt this column at the Enfyra database-query layer. This does not change isUpdatable. Encrypted fields cannot be filtered or sorted.'),
|
|
286
387
|
isPrimary: z.boolean().optional().describe('Set true only for primary key columns; normally only create_table auto id uses this.'),
|
|
287
388
|
isGenerated: z.boolean().optional().describe('Set true only for generated columns such as auto id.'),
|
|
288
389
|
isSystem: z.boolean().optional().describe('Set true only for system-managed columns. Avoid for normal app fields.'),
|
|
@@ -306,11 +407,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
306
407
|
const columnDeleteSchema = {
|
|
307
408
|
tableId: z.string().describe('Table definition ID.'),
|
|
308
409
|
columnId: z.string().describe('Column definition ID to delete.'),
|
|
410
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
309
411
|
};
|
|
310
412
|
|
|
311
413
|
const relationDeleteSchema = {
|
|
312
414
|
tableId: z.string().describe('Table definition ID (source table of the relation).'),
|
|
313
415
|
relationId: z.string().describe('Relation definition ID to delete.'),
|
|
416
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
314
417
|
};
|
|
315
418
|
|
|
316
419
|
// ─── READ ───
|
|
@@ -351,12 +454,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
351
454
|
name: z.string().describe('Table name (e.g., "user_definition", "my_custom_table"). Must be unique, lowercase with underscores.'),
|
|
352
455
|
description: z.string().optional().describe('Description of what this table stores.'),
|
|
353
456
|
isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
|
|
354
|
-
columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, defaultValue?, description?, options? }. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"
|
|
457
|
+
columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, isPublished?, isUpdatable?, isEncrypted?, defaultValue?, description?, options? }. Set isEncrypted=true for values encrypted at rest; set isUpdatable=false separately only when the field should be immutable. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"api_key","type":"varchar","isEncrypted":true,"isPublished":false}]'),
|
|
355
458
|
relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id or {"id": <id>}. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
|
|
356
459
|
indexes: z.string().optional().describe('JSON array of logical index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Example: [["member","isRead","conversation"],["conversation","member","isRead"]]'),
|
|
357
460
|
uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
|
|
358
461
|
},
|
|
359
|
-
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
|
|
462
|
+
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
|
|
360
463
|
const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
|
|
361
464
|
const userColumns = parseJsonArrayParam('columns', columnsJson);
|
|
362
465
|
const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
|
|
@@ -391,7 +494,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
391
494
|
return {
|
|
392
495
|
content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
393
496
|
};
|
|
394
|
-
}
|
|
497
|
+
})
|
|
395
498
|
);
|
|
396
499
|
|
|
397
500
|
// ─── UPDATE TABLE ───
|
|
@@ -414,7 +517,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
414
517
|
indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on table_definition.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
|
|
415
518
|
uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on table_definition.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
|
|
416
519
|
},
|
|
417
|
-
async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => {
|
|
520
|
+
async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => withSchemaQueue(async () => {
|
|
418
521
|
const body = {};
|
|
419
522
|
if (name !== undefined) body.name = name;
|
|
420
523
|
if (alias !== undefined) body.alias = alias;
|
|
@@ -428,7 +531,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
428
531
|
return {
|
|
429
532
|
content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
430
533
|
};
|
|
431
|
-
}
|
|
534
|
+
})
|
|
432
535
|
);
|
|
433
536
|
|
|
434
537
|
// ─── DELETE TABLE ───
|
|
@@ -442,15 +545,30 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
442
545
|
].join(' '),
|
|
443
546
|
{
|
|
444
547
|
tableId: z.string().describe('Table definition ID to delete.'),
|
|
548
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
445
549
|
},
|
|
446
|
-
async ({ tableId }) => {
|
|
550
|
+
async ({ tableId, confirm }) => withSchemaQueue(async () => {
|
|
551
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
552
|
+
if (!confirm) {
|
|
553
|
+
return {
|
|
554
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
555
|
+
action: 'delete_table_preview',
|
|
556
|
+
tableId,
|
|
557
|
+
tableName: tableData.name,
|
|
558
|
+
columnCount: (tableData.columns || []).length,
|
|
559
|
+
relationCount: (tableData.relations || []).length,
|
|
560
|
+
destructive: true,
|
|
561
|
+
next: 'Call delete_table again with confirm=true to delete metadata, routes, derived FK/junction structures, the physical table, and all table data.',
|
|
562
|
+
}, null, 2) }],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
447
565
|
const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
|
|
448
566
|
method: 'DELETE',
|
|
449
567
|
});
|
|
450
568
|
return {
|
|
451
569
|
content: [{ type: 'text', text: `Table ${tableId} deleted.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
452
570
|
};
|
|
453
|
-
}
|
|
571
|
+
})
|
|
454
572
|
);
|
|
455
573
|
|
|
456
574
|
// ─── CREATE COLUMN ───
|
|
@@ -499,11 +617,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
499
617
|
type: z.string().optional().describe('New column type.'),
|
|
500
618
|
isNullable: z.boolean().optional().describe('Set nullable.'),
|
|
501
619
|
isPublished: z.boolean().optional().describe('Set column visibility baseline. false = unpublished (omitted from response unless allowed by field permission rules).'),
|
|
620
|
+
isUpdatable: z.boolean().optional().describe('Set false for immutable fields that should be stripped from update payloads.'),
|
|
502
621
|
defaultValue: z.string().optional().describe('New default value as JSON string.'),
|
|
503
622
|
description: z.string().optional().describe('New description.'),
|
|
504
623
|
options: z.string().optional().describe('New options as JSON string.'),
|
|
505
624
|
},
|
|
506
|
-
async ({ tableId, columnId, name, type, isNullable, isPublished, defaultValue, description, options }) => {
|
|
625
|
+
async ({ tableId, columnId, name, type, isNullable, isPublished, isUpdatable, defaultValue, description, options }) => withSchemaQueue(async () => {
|
|
507
626
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
508
627
|
if (!tableData) {
|
|
509
628
|
return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
|
|
@@ -522,6 +641,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
522
641
|
if (type !== undefined) rest.type = type;
|
|
523
642
|
if (isNullable !== undefined) rest.isNullable = isNullable;
|
|
524
643
|
if (isPublished !== undefined) rest.isPublished = isPublished;
|
|
644
|
+
if (isUpdatable !== undefined) rest.isUpdatable = isUpdatable;
|
|
525
645
|
if (defaultValue !== undefined) rest.defaultValue = defaultValue;
|
|
526
646
|
if (description !== undefined) rest.description = description;
|
|
527
647
|
if (options !== undefined) rest.options = JSON.parse(options);
|
|
@@ -538,7 +658,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
538
658
|
return {
|
|
539
659
|
content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
540
660
|
};
|
|
541
|
-
}
|
|
661
|
+
})
|
|
542
662
|
);
|
|
543
663
|
|
|
544
664
|
// ─── DELETE COLUMN ───
|
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);
|
|
@@ -173,6 +175,8 @@ function summarizeTable(table) {
|
|
|
173
175
|
isPrimary: !!column.isPrimary,
|
|
174
176
|
isNullable: column.isNullable,
|
|
175
177
|
isPublished: column.isPublished,
|
|
178
|
+
isUpdatable: column.isUpdatable !== false,
|
|
179
|
+
isEncrypted: column.isEncrypted === true,
|
|
176
180
|
})),
|
|
177
181
|
relations: (table.relations || []).map((relation) => ({
|
|
178
182
|
id: relation.id ?? relation._id,
|
|
@@ -323,6 +327,35 @@ function resolveFieldOrThrow(table, fieldName, kind = 'column') {
|
|
|
323
327
|
return field;
|
|
324
328
|
}
|
|
325
329
|
|
|
330
|
+
async function prepareGenericMutation(tableName, data) {
|
|
331
|
+
const { tables } = await getMetadataTables();
|
|
332
|
+
return prepareRecordMutation({
|
|
333
|
+
fetchAPI,
|
|
334
|
+
apiUrl: ENFYRA_API_URL,
|
|
335
|
+
tables,
|
|
336
|
+
tableName,
|
|
337
|
+
data,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseQueryParamsArg(queryParams) {
|
|
342
|
+
const parsed = parseJsonArg(queryParams, {});
|
|
343
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
344
|
+
throw new Error('queryParams must be a JSON object string.');
|
|
345
|
+
}
|
|
346
|
+
const params = new URLSearchParams();
|
|
347
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
348
|
+
if (value === undefined || value === null) continue;
|
|
349
|
+
params.set(key, String(value));
|
|
350
|
+
}
|
|
351
|
+
return params.toString();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function appendQuery(path, queryParams) {
|
|
355
|
+
if (!queryParams) return path;
|
|
356
|
+
return `${path}${path.includes('?') ? '&' : '?'}${queryParams}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
326
359
|
// Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
|
|
327
360
|
const server = new McpServer(
|
|
328
361
|
{
|
|
@@ -431,7 +464,7 @@ server.tool(
|
|
|
431
464
|
routeTables: routeTableList,
|
|
432
465
|
noRouteTables,
|
|
433
466
|
canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
|
|
434
|
-
customRouteWorkflow: 'For a new endpoint use create_route
|
|
467
|
+
customRouteWorkflow: 'For a new endpoint use create_route without mainTableId, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
|
|
435
468
|
},
|
|
436
469
|
schemaManagement: {
|
|
437
470
|
createTable: 'POST /table_definition supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly. It does not accept alias at create time; table name drives the default route/schema behavior.',
|
|
@@ -915,31 +948,65 @@ server.tool(
|
|
|
915
948
|
// CRUD TOOLS
|
|
916
949
|
// ============================================================================
|
|
917
950
|
|
|
918
|
-
server.tool('create_record', 'Create a new record in any table', {
|
|
951
|
+
server.tool('create_record', 'Create a new record in any route-backed table. The tool validates body keys against live metadata and validates sourceCode before saving script-backed records.', {
|
|
919
952
|
tableName: z.string().describe('Table name to insert into'),
|
|
920
953
|
data: z.string().describe('Record data as JSON string'),
|
|
921
|
-
|
|
954
|
+
queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
|
|
955
|
+
}, async ({ tableName, data, queryParams }) => {
|
|
922
956
|
validateTableName(tableName);
|
|
923
|
-
const
|
|
924
|
-
|
|
957
|
+
const prepared = await prepareGenericMutation(tableName, data);
|
|
958
|
+
const query = parseQueryParamsArg(queryParams);
|
|
959
|
+
const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
|
|
960
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
961
|
+
...summarizeMutationResult(result, 'created', tableName),
|
|
962
|
+
scriptValidation: prepared.scriptValidation,
|
|
963
|
+
}, null, 2) }] };
|
|
925
964
|
});
|
|
926
965
|
|
|
927
|
-
server.tool('update_record', 'Update an existing record by ID using PATCH', {
|
|
966
|
+
server.tool('update_record', 'Update an existing record by ID using PATCH. The tool validates body keys against live metadata and validates sourceCode before saving script-backed records.', {
|
|
928
967
|
tableName: z.string().describe('Table name'),
|
|
929
968
|
id: z.string().describe('Record ID to update'),
|
|
930
969
|
data: z.string().describe('Fields to update as JSON string'),
|
|
931
|
-
|
|
970
|
+
queryParams: z.string().optional().describe('Optional query params as JSON object string for route contracts that intentionally keep workflow fields out of the validated body.'),
|
|
971
|
+
}, async ({ tableName, id, data, queryParams }) => {
|
|
932
972
|
validateTableName(tableName);
|
|
933
|
-
const
|
|
934
|
-
|
|
973
|
+
const prepared = await prepareGenericMutation(tableName, data);
|
|
974
|
+
const query = parseQueryParamsArg(queryParams);
|
|
975
|
+
const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
|
|
976
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
977
|
+
...summarizeMutationResult(result, 'updated', tableName),
|
|
978
|
+
scriptValidation: prepared.scriptValidation,
|
|
979
|
+
}, null, 2) }] };
|
|
935
980
|
});
|
|
936
981
|
|
|
937
982
|
server.tool('delete_record', 'Delete a record by ID', {
|
|
938
983
|
tableName: z.string().describe('Table name'),
|
|
939
984
|
id: z.string().describe('Record ID to delete'),
|
|
940
|
-
|
|
985
|
+
queryParams: z.string().optional().describe('Optional query params as JSON object string for route-specific confirmation contracts.'),
|
|
986
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
987
|
+
}, async ({ tableName, id, queryParams, confirm }) => {
|
|
941
988
|
validateTableName(tableName);
|
|
942
|
-
const
|
|
989
|
+
const primaryKey = await getPrimaryFieldName(tableName);
|
|
990
|
+
if (!confirm) {
|
|
991
|
+
const query = new URLSearchParams({
|
|
992
|
+
filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
|
|
993
|
+
limit: '1',
|
|
994
|
+
fields: primaryKey,
|
|
995
|
+
});
|
|
996
|
+
const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
|
|
997
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
998
|
+
action: 'delete_record_preview',
|
|
999
|
+
tableName,
|
|
1000
|
+
id,
|
|
1001
|
+
primaryKey,
|
|
1002
|
+
preview: preview?.data?.[0] || null,
|
|
1003
|
+
previewError: preview?.error,
|
|
1004
|
+
destructive: true,
|
|
1005
|
+
next: 'Call delete_record again with confirm=true to delete this route-backed record.',
|
|
1006
|
+
}, null, 2) }] };
|
|
1007
|
+
}
|
|
1008
|
+
const query = parseQueryParamsArg(queryParams);
|
|
1009
|
+
const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'DELETE' });
|
|
943
1010
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
944
1011
|
action: 'deleted',
|
|
945
1012
|
tableName,
|
|
@@ -1406,15 +1473,18 @@ server.tool(
|
|
|
1406
1473
|
},
|
|
1407
1474
|
async ({ path: routePath, mainTableId, methods, publishedMethods, isEnabled, description }) => {
|
|
1408
1475
|
const methodMap = await getMethodMap();
|
|
1476
|
+
const normalizedPath = normalizeRestPath(routePath);
|
|
1409
1477
|
|
|
1410
1478
|
const body = {
|
|
1411
|
-
path:
|
|
1479
|
+
path: normalizedPath,
|
|
1412
1480
|
isEnabled,
|
|
1413
1481
|
description,
|
|
1414
1482
|
availableMethods: resolveMethodIds(methodMap, methods),
|
|
1415
1483
|
};
|
|
1416
1484
|
|
|
1417
1485
|
if (mainTableId !== undefined && mainTableId !== null) {
|
|
1486
|
+
const { tables } = await getMetadataTables();
|
|
1487
|
+
validateMainTableRoutePath(tables, mainTableId, normalizedPath);
|
|
1418
1488
|
body.mainTable = { id: mainTableId };
|
|
1419
1489
|
}
|
|
1420
1490
|
|
|
@@ -1470,6 +1540,10 @@ server.tool(
|
|
|
1470
1540
|
if (methodNames.length === 0) throw new Error('Provide method or methods');
|
|
1471
1541
|
const methodMap = await getMethodMap();
|
|
1472
1542
|
const results = [];
|
|
1543
|
+
const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'route_handler_definition', {
|
|
1544
|
+
sourceCode,
|
|
1545
|
+
scriptLanguage,
|
|
1546
|
+
});
|
|
1473
1547
|
|
|
1474
1548
|
for (const methodName of methodNames) {
|
|
1475
1549
|
const methodId = methodMap[methodName.toUpperCase()];
|
|
@@ -1497,6 +1571,7 @@ server.tool(
|
|
|
1497
1571
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1498
1572
|
action: 'created',
|
|
1499
1573
|
handlers: results,
|
|
1574
|
+
scriptValidation,
|
|
1500
1575
|
routesReloaded: true,
|
|
1501
1576
|
detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
|
|
1502
1577
|
}, null, 2) }] };
|
|
@@ -1515,14 +1590,19 @@ server.tool(
|
|
|
1515
1590
|
routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
|
|
1516
1591
|
name: z.string().describe('Hook name (unique per route)'),
|
|
1517
1592
|
code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
|
|
1593
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
|
|
1518
1594
|
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
|
|
1519
1595
|
.describe('Methods this hook applies to. Default: all REST methods.'),
|
|
1520
1596
|
priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
|
|
1521
1597
|
isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
|
|
1522
1598
|
},
|
|
1523
|
-
async ({ routeId, name, code, methods, priority, isEnabled }) => {
|
|
1599
|
+
async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
|
|
1524
1600
|
const methodMap = await getMethodMap();
|
|
1525
1601
|
const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
|
|
1602
|
+
const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'pre_hook_definition', {
|
|
1603
|
+
sourceCode: code,
|
|
1604
|
+
scriptLanguage,
|
|
1605
|
+
});
|
|
1526
1606
|
|
|
1527
1607
|
const result = await fetchAPI(ENFYRA_API_URL, '/pre_hook_definition', {
|
|
1528
1608
|
method: 'POST',
|
|
@@ -1530,7 +1610,7 @@ server.tool(
|
|
|
1530
1610
|
route: { id: routeId },
|
|
1531
1611
|
name,
|
|
1532
1612
|
sourceCode: code,
|
|
1533
|
-
scriptLanguage
|
|
1613
|
+
scriptLanguage,
|
|
1534
1614
|
methods: resolveMethodIds(methodMap, methodNames),
|
|
1535
1615
|
priority,
|
|
1536
1616
|
isEnabled,
|
|
@@ -1540,7 +1620,15 @@ server.tool(
|
|
|
1540
1620
|
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
1541
1621
|
|
|
1542
1622
|
const created = firstDataRecord(result);
|
|
1543
|
-
return { content: [{ type: 'text', text:
|
|
1623
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1624
|
+
action: 'created',
|
|
1625
|
+
kind: 'pre_hook',
|
|
1626
|
+
id: getId(created),
|
|
1627
|
+
name,
|
|
1628
|
+
routeId,
|
|
1629
|
+
scriptValidation,
|
|
1630
|
+
routesReloaded: true,
|
|
1631
|
+
}, null, 2) }] };
|
|
1544
1632
|
},
|
|
1545
1633
|
);
|
|
1546
1634
|
|
|
@@ -1556,14 +1644,19 @@ server.tool(
|
|
|
1556
1644
|
routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
|
|
1557
1645
|
name: z.string().describe('Hook name (unique per route)'),
|
|
1558
1646
|
code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
|
|
1647
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
|
|
1559
1648
|
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
|
|
1560
1649
|
.describe('Methods this hook applies to. Default: all REST methods.'),
|
|
1561
1650
|
priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
|
|
1562
1651
|
isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
|
|
1563
1652
|
},
|
|
1564
|
-
async ({ routeId, name, code, methods, priority, isEnabled }) => {
|
|
1653
|
+
async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
|
|
1565
1654
|
const methodMap = await getMethodMap();
|
|
1566
1655
|
const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
|
|
1656
|
+
const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'post_hook_definition', {
|
|
1657
|
+
sourceCode: code,
|
|
1658
|
+
scriptLanguage,
|
|
1659
|
+
});
|
|
1567
1660
|
|
|
1568
1661
|
const result = await fetchAPI(ENFYRA_API_URL, '/post_hook_definition', {
|
|
1569
1662
|
method: 'POST',
|
|
@@ -1571,7 +1664,7 @@ server.tool(
|
|
|
1571
1664
|
route: { id: routeId },
|
|
1572
1665
|
name,
|
|
1573
1666
|
sourceCode: code,
|
|
1574
|
-
scriptLanguage
|
|
1667
|
+
scriptLanguage,
|
|
1575
1668
|
methods: resolveMethodIds(methodMap, methodNames),
|
|
1576
1669
|
priority,
|
|
1577
1670
|
isEnabled,
|
|
@@ -1581,7 +1674,15 @@ server.tool(
|
|
|
1581
1674
|
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
1582
1675
|
|
|
1583
1676
|
const created = firstDataRecord(result);
|
|
1584
|
-
return { content: [{ type: 'text', text:
|
|
1677
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1678
|
+
action: 'created',
|
|
1679
|
+
kind: 'post_hook',
|
|
1680
|
+
id: getId(created),
|
|
1681
|
+
name,
|
|
1682
|
+
routeId,
|
|
1683
|
+
scriptValidation,
|
|
1684
|
+
routesReloaded: true,
|
|
1685
|
+
}, null, 2) }] };
|
|
1585
1686
|
},
|
|
1586
1687
|
);
|
|
1587
1688
|
|