@enfyra/mcp-server 0.0.52 → 0.0.54
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/package.json +3 -2
- package/src/lib/mcp-examples.js +31 -0
- package/src/lib/mcp-instructions.js +15 -8
- package/src/lib/table-tools.js +101 -14
- package/src/mcp-server-entry.mjs +218 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enfyra/mcp-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.54",
|
|
4
4
|
"description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "node src/index.mjs",
|
|
18
18
|
"dev": "node --watch src/index.mjs",
|
|
19
|
-
"mcp:config": "node src/index.mjs config"
|
|
19
|
+
"mcp:config": "node src/index.mjs config",
|
|
20
|
+
"test": "node --test"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
package/src/lib/mcp-examples.js
CHANGED
|
@@ -216,6 +216,20 @@ create_column({
|
|
|
216
216
|
title: 'REST queries, filters, meta counts, and deep relation fetches',
|
|
217
217
|
useWhen: 'Use when fetching records, filtering by relations, loading nested data, or counting efficiently.',
|
|
218
218
|
examples: [
|
|
219
|
+
{
|
|
220
|
+
name: 'Minimal MCP query then explicit detail query',
|
|
221
|
+
code: `query_table({
|
|
222
|
+
tableName: "user_definition",
|
|
223
|
+
fields: ["id", "email"],
|
|
224
|
+
filter: "{\\"email\\":{\\"_contains\\":\\"@example.com\\"}}",
|
|
225
|
+
limit: 10
|
|
226
|
+
})`,
|
|
227
|
+
notes: [
|
|
228
|
+
'Always pass fields when you need more than ids; query_table without fields intentionally returns only the primary key.',
|
|
229
|
+
'Use inspect_table first when you do not know valid column names or relation propertyName values.',
|
|
230
|
+
'Use count_records when only the count is needed.',
|
|
231
|
+
],
|
|
232
|
+
},
|
|
219
233
|
{
|
|
220
234
|
name: 'List current user conversations through RLS',
|
|
221
235
|
code: `GET /enfyra/chat_conversation?fields=id,kind,title,lastMessage.id,lastMessage.text,lastMessage.createdAt&limit=0`,
|
|
@@ -269,6 +283,23 @@ create_column({
|
|
|
269
283
|
title: 'Custom handlers, pre-hooks, post-hooks, and script macros',
|
|
270
284
|
useWhen: 'Use when writing Enfyra dynamic JavaScript for REST behavior.',
|
|
271
285
|
examples: [
|
|
286
|
+
{
|
|
287
|
+
name: 'Create a route handler with current script fields',
|
|
288
|
+
code: `create_handler({
|
|
289
|
+
routeId: "<route_id>",
|
|
290
|
+
method: "POST",
|
|
291
|
+
scriptLanguage: "javascript",
|
|
292
|
+
sourceCode: \`const email = @BODY.email
|
|
293
|
+
if (!email) @THROW400("Email is required")
|
|
294
|
+
|
|
295
|
+
return { ok: true, email }\`
|
|
296
|
+
})`,
|
|
297
|
+
notes: [
|
|
298
|
+
'Use sourceCode, not logic. The server generates compiledCode.',
|
|
299
|
+
'Use method for one handler, or methods only when the same sourceCode should be saved for multiple methods.',
|
|
300
|
+
'Do not pass name to route_handler_definition; one handler is identified by route + method.',
|
|
301
|
+
],
|
|
302
|
+
},
|
|
272
303
|
{
|
|
273
304
|
name: 'Custom register handler',
|
|
274
305
|
code: `const email = @BODY.email
|
|
@@ -36,6 +36,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
36
36
|
'- If generating concrete code, schema payloads, SSR app config, OAuth wiring, Socket.IO clients/events, flows, files, extensions, or permission/RLS examples, call **`get_enfyra_examples`** for the matching category before writing the final answer. Examples are grouped by category and are intentionally more concrete than these global rules.',
|
|
37
37
|
'- Treat hardcoded instructions as operating rules, but use live discovery as the final check for this running instance. Do not infer missing capabilities from a narrow tool schema; check metadata/routes or the relevant specialized tool first.',
|
|
38
38
|
'- If there is no dedicated MCP tool for a subsystem, use the route-backed metadata table with `query_table` / `create_record` / `update_record` / `delete_record`, after confirming that table has a route. If the table is no-route, use the canonical specialized tool or parent table workflow instead.',
|
|
39
|
+
'- MCP read tools are intentionally **minimal by default**. `query_table` without `fields` returns only the table primary key with a small hint. Always pass explicit `fields` when you need details, and use `inspect_table` / `inspect_route` before guessing field names.',
|
|
40
|
+
'- MCP mutation tools return only ids/status by default. If you need the saved row, immediately call `find_one_record` or `query_table` with explicit `fields`; do not expect create/update tools to echo full records.',
|
|
39
41
|
'',
|
|
40
42
|
'### Capability map (current Enfyra system)',
|
|
41
43
|
'- **Schema/metadata:** `table_definition`, `relation_definition`, and schema tools manage tables, columns, relations, validation, and migrations. `column_definition` is internal/no-route; columns are created/updated through table schema operations.',
|
|
@@ -70,10 +72,11 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
70
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.',
|
|
71
73
|
'- Use **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, **`create_route_permission`** for authenticated route access, and **`create_guard`** for pre/post-auth request gates.',
|
|
72
74
|
'- Prefer these REST inspection/operator tools over raw `query_table` on system tables when changing route behavior. They resolve ids, methods, route paths, code previews, and cache reloads for the model.',
|
|
73
|
-
'- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`**
|
|
75
|
+
'- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** and **omit `mainTableId`**. `mainTable` is only a marker for canonical table routes like `/orders`; custom paths such as `/orders/stats`, `/cloud/admin/hosts`, `/auth/login`, or `/me` must not set it.',
|
|
74
76
|
'- **Wrong pattern:** calling **`create_table`** just to get an HTTP path, then overriding handlers on the **default** auto route `/{table_name}`. That adds unnecessary schema and breaks the usual CRUD surface for that table.',
|
|
75
77
|
'- **`create_table`** is only when the user needs **new persisted data** (new entity + columns). It is **not** the right tool when the goal is only a new path or custom script.',
|
|
76
|
-
'- **Right pattern:** **`create_route`** → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create).
|
|
78
|
+
'- **Right pattern:** **`create_route`** without `mainTableId` → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create). Handler/hook code must query explicit repos such as `$ctx.$repos.orders`; do not rely on `$repos.main` for custom routes.',
|
|
79
|
+
'- **Handler contract:** `create_handler` takes `routeId`, `method` (or `methods` for batch), `sourceCode`, optional `scriptLanguage`, and optional `timeout`. Do **not** send `logic`, `name`, or `compiledCode`; backend CRUD rejects `logic` and `compiledCode` is generated by the server.',
|
|
77
80
|
'',
|
|
78
81
|
'### After a new table is created',
|
|
79
82
|
'- MCP **`create_table` supports creating columns and relations in the same call**: pass `columns` and `relations` as JSON arrays. Use `create_relation` only when adding a relation to an existing table later.',
|
|
@@ -103,8 +106,10 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
103
106
|
`- **No** **GET** \`${base}/<table_name>/<id>\`. For one row by id use **GET** \`${getOneById}\` or MCP \`query_table\` / \`find_one_record\`.`,
|
|
104
107
|
'',
|
|
105
108
|
'### Relation field format (create_record / update_record)',
|
|
106
|
-
'-
|
|
107
|
-
'
|
|
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
|
+
'- Relation fields (publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
|
|
111
|
+
'- **mainTable warning:** do not set `mainTable` on custom routes. It is reserved for canonical table routes only.',
|
|
112
|
+
' - **Many-to-one:** `"someRelation": {"id": 4}` (single object with id)',
|
|
108
113
|
' - **One-to-many / many-to-many:** `"publishedMethods": [{"id": 1}, {"id": 2}]` (array of objects with id)',
|
|
109
114
|
'- **Method IDs** (for REST route publishedMethods, availableMethods, skipRoleGuardMethods): GET=1, POST=2, PATCH=3, DELETE=4. Query `method_definition` table if unsure.',
|
|
110
115
|
'- **Wrong:** `"publishedMethods": ["GET"]` or `"publishedMethods": [{"method": "GET"}]` — rejected or silently ignored.',
|
|
@@ -134,6 +139,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
134
139
|
'- For encrypted persisted fields such as `*_encrypted`, use an Enfyra route pre-hook, not a Knex/database hook. Mutate the body before persistence: `const value = @BODY.field_encrypted; if (value && value.slice(0, 7) !== "enc:v1:") @BODY.field_encrypted = @HELPERS.$encrypt.encrypt(value);`.',
|
|
135
140
|
'- ASV exposes `$helpers.$encrypt.encrypt/decrypt` for encrypted strings and `$helpers.$ssh.generateKeyPair` for SSH keys. Do not generate `$helpers.$secrets` usage.',
|
|
136
141
|
'- 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
|
+
'- 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.',
|
|
137
143
|
'- MCP `create_pre_hook` and `create_post_hook` accept a user-facing `code` argument but persist it as `sourceCode` with `scriptLanguage`. Do not call raw `create_record` with a `code` field for hook tables; backend request validation rejects `code` on REST CRUD.',
|
|
138
144
|
'- Enfyra Cloud host provisioning with PgBouncer must preserve tenant database isolation. PgBouncer should use per-tenant `DATABASE_URLS` entries with each tenant `db_user`, `db_password`, and `db_name`, and PostgreSQL 16/SCRAM hosts need `AUTH_TYPE=plain`. Do not route all tenants through PostgreSQL `postgres` just to make PgBouncer connect; that bypasses tenant DB permissions.',
|
|
139
145
|
'- Enfyra Cloud Docker health checks must compare exact healthy states. `true healthy` passes, `true starting` keeps waiting, and `true unhealthy` fails. Do not use broad substring logic where `unhealthy` accidentally counts as healthy.',
|
|
@@ -192,10 +198,11 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
192
198
|
'### System tables — which have REST routes',
|
|
193
199
|
'- **Not all system tables have a REST route.** `query_table`, `find_one_record`, `create_record`, etc. all go through the dynamic REST API and will return **404** if the table has no registered route.',
|
|
194
200
|
'- **`column_definition` and `session_definition` have NO route** — do NOT call `query_table("column_definition", …)` or `query_table("session_definition", …)`. They will 404.',
|
|
195
|
-
'-
|
|
201
|
+
'- Do not invent singular/legacy system route names such as `hook_definition`, `oauth_provider_definition`, or physical FK tables. If a route name is not listed by `get_all_routes`, it is not a REST endpoint for generic CRUD. Use the concrete tables (`pre_hook_definition`, `post_hook_definition`, `oauth_config_definition`, etc.) or the dedicated MCP tool.',
|
|
202
|
+
'- 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.',
|
|
196
203
|
'- **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`.',
|
|
197
|
-
'- **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.',
|
|
198
|
-
'- 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.',
|
|
204
|
+
'- **Tables without REST routes (internal/system only):** `column_definition`, `session_definition`. Columns are managed indirectly via cascade on `table_definition` (POST/PATCH with columns arrays). The `create_table`, `create_column`/`add_column`, `update_column`, and `delete_column`/`remove_column` MCP tools handle this automatically by reading full table metadata first.',
|
|
205
|
+
'- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isPublished=false` directly when creating secret/internal fields such as `*_encrypted`. When patching an existing table, only persisted columns with an `id`/`_id` belong in the cascade payload; metadata projections such as `createdAt`, `updatedAt`, or relation-derived FK display fields without an id are not valid column-definition patch rows. Never rebuild a schema cascade from `table_definition?fields=columns.*`, because nested relation fields may be paginated/truncated.',
|
|
199
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.',
|
|
200
207
|
'',
|
|
201
208
|
'### Body validation & column rules',
|
|
@@ -214,7 +221,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
214
221
|
'',
|
|
215
222
|
'### Resolving the real REST path',
|
|
216
223
|
'- Do **not** assume `route_definition.path` always equals `table_definition.name`. Paths are data-driven (custom prefixes, renames, multiple routes per table).',
|
|
217
|
-
'- When unsure of the URL path, use MCP **`get_all_routes`** (or **`get_all_metadata`**) to read each route’s **path
|
|
224
|
+
'- When unsure of the URL path, use MCP **`get_all_routes`** (or **`get_all_metadata`**) to read each route’s **path**. Treat `mainTable` as canonical CRUD-route metadata only, not as the owner table for custom routes.',
|
|
218
225
|
'',
|
|
219
226
|
'### MongoDB vs SQL primary key',
|
|
220
227
|
'- On **SQL**, filters often use **`id`**. On **MongoDB**, documents may use **`_id`** — a filter for one row might be `{"_id":{"_eq":"..."}}` instead of `id`, depending on metadata.',
|
package/src/lib/table-tools.js
CHANGED
|
@@ -4,13 +4,42 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { fetchAPI } from './fetch.js';
|
|
6
6
|
|
|
7
|
+
export function normalizeTablesFromMetadata(metadata) {
|
|
8
|
+
const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
|
|
9
|
+
return Array.isArray(tablesSource)
|
|
10
|
+
? tablesSource
|
|
11
|
+
: Object.values(tablesSource || {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveTableFromMetadata(metadata, tableId) {
|
|
15
|
+
return normalizeTablesFromMetadata(metadata)
|
|
16
|
+
.find((table) => String(getId(table)) === String(tableId)) || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
/**
|
|
8
|
-
* Helper: fetch table with columns and relations
|
|
20
|
+
* Helper: fetch table with full columns and relations.
|
|
21
|
+
* Dynamic table_definition relation fields can be paginated/truncated, so schema
|
|
22
|
+
* cascade tools must use /metadata as the complete source of columns/relations.
|
|
9
23
|
*/
|
|
10
|
-
async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
|
|
24
|
+
export async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
|
|
11
25
|
const filter = encodeURIComponent(JSON.stringify({ id: { _eq: tableId } }));
|
|
12
|
-
const
|
|
13
|
-
|
|
26
|
+
const [tableResult, metadata] = await Promise.all([
|
|
27
|
+
fetchAPI(ENFYRA_API_URL, `/table_definition?filter=${filter}&limit=1&fields=*`),
|
|
28
|
+
fetchAPI(ENFYRA_API_URL, '/metadata'),
|
|
29
|
+
]);
|
|
30
|
+
const tableData = tableResult?.data?.[0] || tableResult?.[0] || null;
|
|
31
|
+
const metadataTable = resolveTableFromMetadata(metadata, tableId);
|
|
32
|
+
if (!metadataTable) {
|
|
33
|
+
throw new Error(`Full metadata for table ${tableId} was not found; refusing schema cascade patch.`);
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(metadataTable.columns)) {
|
|
36
|
+
throw new Error(`Full metadata for table ${tableId} did not include columns; refusing schema cascade patch.`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
...(tableData || metadataTable),
|
|
40
|
+
columns: metadataTable.columns,
|
|
41
|
+
relations: Array.isArray(metadataTable.relations) ? metadataTable.relations : [],
|
|
42
|
+
};
|
|
14
43
|
}
|
|
15
44
|
|
|
16
45
|
/**
|
|
@@ -99,6 +128,41 @@ function getPatchableColumns(columns) {
|
|
|
99
128
|
.map(normalizeColumnForTablePatch);
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
function getMissingIds(beforeIds, afterIds, excludedIds = []) {
|
|
132
|
+
const afterSet = new Set(afterIds.map(String));
|
|
133
|
+
const excludedSet = new Set(excludedIds.map(String));
|
|
134
|
+
return beforeIds
|
|
135
|
+
.map(String)
|
|
136
|
+
.filter((id) => !excludedSet.has(id) && !afterSet.has(id));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
140
|
+
action,
|
|
141
|
+
columnId,
|
|
142
|
+
columnName,
|
|
143
|
+
}) {
|
|
144
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
145
|
+
const afterColumns = getPatchableColumns(tableData.columns);
|
|
146
|
+
const afterIds = afterColumns.map((column) => String(getId(column)));
|
|
147
|
+
const excludedIds = action === 'delete' ? [columnId] : [];
|
|
148
|
+
const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
|
|
149
|
+
if (missingIds.length > 0) {
|
|
150
|
+
throw new Error(`Schema cascade verification failed: unrelated column ids disappeared: ${missingIds.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (action === 'create' && !afterColumns.some((column) => column.name === columnName)) {
|
|
154
|
+
throw new Error(`Schema cascade verification failed: column "${columnName}" was not found after create.`);
|
|
155
|
+
}
|
|
156
|
+
if (action === 'delete' && afterIds.includes(String(columnId))) {
|
|
157
|
+
throw new Error(`Schema cascade verification failed: column ${columnId} still exists after delete.`);
|
|
158
|
+
}
|
|
159
|
+
if (action === 'update' && !afterIds.includes(String(columnId))) {
|
|
160
|
+
throw new Error(`Schema cascade verification failed: column ${columnId} was not found after update.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return afterColumns;
|
|
164
|
+
}
|
|
165
|
+
|
|
102
166
|
function buildColumnDefinition({
|
|
103
167
|
name,
|
|
104
168
|
type,
|
|
@@ -138,8 +202,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
138
202
|
}
|
|
139
203
|
|
|
140
204
|
const existingColumns = getPatchableColumns(tableData.columns);
|
|
205
|
+
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
141
206
|
const newCol = buildColumnDefinition(args);
|
|
142
207
|
const result = await patchTableAutoConfirm(ENFYRA_API_URL, args.tableId, { columns: [...existingColumns, newCol] });
|
|
208
|
+
await verifyColumnCascade(ENFYRA_API_URL, args.tableId, beforeIds, {
|
|
209
|
+
action: 'create',
|
|
210
|
+
columnName: args.name,
|
|
211
|
+
});
|
|
143
212
|
|
|
144
213
|
return {
|
|
145
214
|
content: [{ type: 'text', text: `Column "${args.name}" added to table ${args.tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
@@ -170,12 +239,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
170
239
|
return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
|
|
171
240
|
}
|
|
172
241
|
|
|
173
|
-
const
|
|
174
|
-
|
|
242
|
+
const existingColumns = getPatchableColumns(tableData.columns);
|
|
243
|
+
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
244
|
+
if (!beforeIds.includes(String(columnId))) {
|
|
245
|
+
throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const columns = existingColumns
|
|
175
249
|
.filter(col => String(getId(col)) !== String(columnId))
|
|
176
|
-
.map(normalizeColumnForTablePatch);
|
|
177
250
|
|
|
178
251
|
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
|
|
252
|
+
await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
253
|
+
action: 'delete',
|
|
254
|
+
columnId,
|
|
255
|
+
});
|
|
179
256
|
|
|
180
257
|
return {
|
|
181
258
|
content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
@@ -256,7 +333,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
256
333
|
'create_table',
|
|
257
334
|
[
|
|
258
335
|
'Create a new table definition with an auto-included `id` primary key column.',
|
|
259
|
-
'**Not** for adding a custom API path or handler only — for that use **`create_route`**
|
|
336
|
+
'**Not** for adding a custom API path or handler only — for that use **`create_route`** without `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
|
|
260
337
|
'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
|
|
261
338
|
'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","isRead","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
|
|
262
339
|
'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id or {id}.',
|
|
@@ -383,7 +460,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
383
460
|
[
|
|
384
461
|
'Add a column to an existing table via PATCH /table_definition/{tableId}.',
|
|
385
462
|
'Columns are managed through cascade with table_definition — there is NO direct /column_definition endpoint.',
|
|
386
|
-
'This tool
|
|
463
|
+
'This tool reads full table metadata, keeps only persisted column rows with id/_id, appends the new one, PATCHes the table, and verifies unrelated columns survived.',
|
|
387
464
|
'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are not valid cascade rows and are skipped.',
|
|
388
465
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
389
466
|
].join(' '),
|
|
@@ -398,7 +475,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
398
475
|
[
|
|
399
476
|
'Alias for create_column. Add a column to an existing table through the canonical table_definition cascade.',
|
|
400
477
|
'Use this for schema additions, including hidden secret fields with isPublished=false.',
|
|
401
|
-
'
|
|
478
|
+
'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
|
|
402
479
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
403
480
|
].join(' '),
|
|
404
481
|
columnCreateSchema,
|
|
@@ -411,7 +488,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
411
488
|
'update_column',
|
|
412
489
|
[
|
|
413
490
|
'Update an existing column on a table via PATCH /table_definition/{tableId}.',
|
|
414
|
-
'
|
|
491
|
+
'Reads full table metadata, keeps only persisted rows with id/_id, modifies the target column, PATCHes the table, and verifies unrelated columns survived.',
|
|
415
492
|
'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are skipped.',
|
|
416
493
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
417
494
|
].join(' '),
|
|
@@ -432,7 +509,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
432
509
|
return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
|
|
433
510
|
}
|
|
434
511
|
|
|
435
|
-
const
|
|
512
|
+
const existingColumns = getPatchableColumns(tableData.columns);
|
|
513
|
+
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
514
|
+
if (!beforeIds.includes(String(columnId))) {
|
|
515
|
+
throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const columns = existingColumns.map(col => {
|
|
436
519
|
const rest = normalizeColumnForTablePatch(col);
|
|
437
520
|
if (String(getId(col)) === String(columnId)) {
|
|
438
521
|
if (name !== undefined) rest.name = name;
|
|
@@ -447,6 +530,10 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
447
530
|
});
|
|
448
531
|
|
|
449
532
|
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
|
|
533
|
+
await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
534
|
+
action: 'update',
|
|
535
|
+
columnId,
|
|
536
|
+
});
|
|
450
537
|
|
|
451
538
|
return {
|
|
452
539
|
content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
@@ -460,7 +547,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
460
547
|
'delete_column',
|
|
461
548
|
[
|
|
462
549
|
'Delete a column from a table via PATCH /table_definition/{tableId}.',
|
|
463
|
-
'
|
|
550
|
+
'Reads full table metadata, keeps only persisted rows with id/_id, removes the target, PATCHes the table, and verifies unrelated columns survived.',
|
|
464
551
|
'The physical column is dropped from the database. System columns (id, createdAt, updatedAt) cannot be deleted.',
|
|
465
552
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
466
553
|
].join(' '),
|
|
@@ -475,7 +562,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
475
562
|
[
|
|
476
563
|
'Alias for delete_column. Remove a column through the canonical table_definition cascade.',
|
|
477
564
|
'This drops the physical column. Confirm destructive schema changes before calling.',
|
|
478
|
-
'
|
|
565
|
+
'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
|
|
479
566
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
480
567
|
].join(' '),
|
|
481
568
|
columnDeleteSchema,
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const CAPABILITY_AREAS = [
|
|
|
32
32
|
{
|
|
33
33
|
area: 'Dynamic REST API',
|
|
34
34
|
tables: ['route_definition', 'route_handler_definition', 'pre_hook_definition', 'post_hook_definition', 'route_permission_definition', 'method_definition'],
|
|
35
|
-
workflow: 'Create paths with create_route
|
|
35
|
+
workflow: 'Create custom paths with create_route without mainTableId, then add handlers/hooks. mainTableId is only for canonical table routes like /table_name. REST methods are GET/POST/PATCH/DELETE.',
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
area: 'Auth, roles, sessions, OAuth',
|
|
@@ -199,6 +199,31 @@ function summarizeRoutes(routesResult) {
|
|
|
199
199
|
}));
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
function summarizeMetadata(metadata, { search, limit } = {}) {
|
|
203
|
+
const tables = normalizeTables(metadata);
|
|
204
|
+
const q = search ? search.toLowerCase() : null;
|
|
205
|
+
const summarized = tables.map((table) => ({
|
|
206
|
+
id: table.id ?? table._id,
|
|
207
|
+
name: table.name,
|
|
208
|
+
alias: table.alias,
|
|
209
|
+
primaryKey: getPrimaryColumn(table)?.name || null,
|
|
210
|
+
columnCount: (table.columns || []).length,
|
|
211
|
+
relationCount: (table.relations || []).length,
|
|
212
|
+
routeHint: `Use get_table_metadata({ tableName: "${table.name}" }) for fields and relations.`,
|
|
213
|
+
}));
|
|
214
|
+
const matched = q
|
|
215
|
+
? summarized.filter((table) => JSON.stringify(table).toLowerCase().includes(q))
|
|
216
|
+
: summarized;
|
|
217
|
+
const outputLimit = limit || 30;
|
|
218
|
+
return {
|
|
219
|
+
tableCount: tables.length,
|
|
220
|
+
matchedTableCount: matched.length,
|
|
221
|
+
returnedTableCount: Math.min(matched.length, outputLimit),
|
|
222
|
+
search: search || null,
|
|
223
|
+
tables: matched.slice(0, outputLimit),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
202
227
|
function unwrapData(result) {
|
|
203
228
|
return Array.isArray(result?.data) ? result.data : [];
|
|
204
229
|
}
|
|
@@ -250,6 +275,29 @@ function pickCodeSummary(record, fieldName) {
|
|
|
250
275
|
};
|
|
251
276
|
}
|
|
252
277
|
|
|
278
|
+
function summarizeMutationResult(result, action, tableName) {
|
|
279
|
+
const record = firstDataRecord(result);
|
|
280
|
+
return {
|
|
281
|
+
action,
|
|
282
|
+
tableName,
|
|
283
|
+
id: getId(record),
|
|
284
|
+
statusCode: result?.statusCode,
|
|
285
|
+
success: result?.success,
|
|
286
|
+
detailHint: `Use find_one_record or query_table with explicit fields to inspect ${tableName}.`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function getTableSummary(tableName) {
|
|
291
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
|
|
292
|
+
const table = result?.data?.table || result?.data || result?.table || result;
|
|
293
|
+
return summarizeTable(table);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function getPrimaryFieldName(tableName) {
|
|
297
|
+
const table = await getTableSummary(tableName);
|
|
298
|
+
return table?.primaryKey || 'id';
|
|
299
|
+
}
|
|
300
|
+
|
|
253
301
|
async function fetchAll(path) {
|
|
254
302
|
return unwrapData(await fetchAPI(ENFYRA_API_URL, path));
|
|
255
303
|
}
|
|
@@ -290,16 +338,38 @@ const server = new McpServer(
|
|
|
290
338
|
// METADATA TOOLS
|
|
291
339
|
// ============================================================================
|
|
292
340
|
|
|
293
|
-
server.tool('get_all_metadata', 'Get
|
|
341
|
+
server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Use get_table_metadata or inspect_table for detail.', {
|
|
342
|
+
includeFull: z.boolean().optional().default(false).describe('Return full raw metadata. Default false to keep MCP context small.'),
|
|
343
|
+
search: z.string().optional().describe('Optional table-name/alias substring filter.'),
|
|
344
|
+
limit: z.number().optional().describe('Maximum tables returned after search. Default 30.'),
|
|
345
|
+
}, async ({ includeFull, search, limit }) => {
|
|
294
346
|
const result = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
295
|
-
|
|
347
|
+
const payload = includeFull
|
|
348
|
+
? result
|
|
349
|
+
: {
|
|
350
|
+
statusCode: result?.statusCode,
|
|
351
|
+
success: result?.success,
|
|
352
|
+
...summarizeMetadata(result, { search, limit }),
|
|
353
|
+
detailHint: 'Default response is capped and minimal. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for columns, relations, and route context.',
|
|
354
|
+
};
|
|
355
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
296
356
|
});
|
|
297
357
|
|
|
298
|
-
server.tool('get_table_metadata', 'Get metadata for a specific table by name', {
|
|
358
|
+
server.tool('get_table_metadata', 'Get concise metadata for a specific table by name', {
|
|
299
359
|
tableName: z.string().describe('Table name (e.g., "user_definition", "route_definition")'),
|
|
300
|
-
|
|
360
|
+
includeFull: z.boolean().optional().default(false).describe('Return full raw table metadata. Default false to keep MCP context small.'),
|
|
361
|
+
}, async ({ tableName, includeFull }) => {
|
|
301
362
|
const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
|
|
302
|
-
|
|
363
|
+
const table = result?.data?.table || result?.data || result?.table || result;
|
|
364
|
+
const payload = includeFull
|
|
365
|
+
? result
|
|
366
|
+
: {
|
|
367
|
+
statusCode: result?.statusCode,
|
|
368
|
+
success: result?.success,
|
|
369
|
+
table: summarizeTable(table),
|
|
370
|
+
queryHint: `Use query_table({ tableName: "${tableName}", fields: [...] }) for records. query_table without fields returns only the primary key.`,
|
|
371
|
+
};
|
|
372
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
303
373
|
});
|
|
304
374
|
|
|
305
375
|
server.tool(
|
|
@@ -707,27 +777,41 @@ server.tool(
|
|
|
707
777
|
},
|
|
708
778
|
);
|
|
709
779
|
|
|
710
|
-
server.tool('query_table', 'Query any table
|
|
780
|
+
server.tool('query_table', 'Query any route-backed table. Default response is minimal; pass fields explicitly for detail.', {
|
|
711
781
|
tableName: z.string().describe('Table name to query'),
|
|
712
782
|
filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
|
|
713
783
|
sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
|
|
714
784
|
page: z.number().optional().describe('Page number (default: 1)'),
|
|
715
|
-
limit: z.number().optional().describe('Items per page
|
|
716
|
-
fields: z.array(z.string()).optional().describe('Fields to select'),
|
|
785
|
+
limit: z.number().optional().describe('Items per page. Default: 10. Use count_records for counts.'),
|
|
786
|
+
fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
|
|
717
787
|
}, async ({ tableName, filter, sort, page, limit, fields }) => {
|
|
718
788
|
validateTableName(tableName);
|
|
719
789
|
validateFilter(filter);
|
|
720
790
|
|
|
721
791
|
const queryParams = new URLSearchParams();
|
|
792
|
+
const selectedFields = fields && fields.length > 0 ? fields : [await getPrimaryFieldName(tableName)];
|
|
722
793
|
if (filter) queryParams.set('filter', filter);
|
|
723
794
|
if (sort) queryParams.set('sort', sort);
|
|
724
795
|
if (page) queryParams.set('page', String(page));
|
|
725
|
-
|
|
726
|
-
|
|
796
|
+
queryParams.set('limit', String(limit || 10));
|
|
797
|
+
queryParams.set('fields', selectedFields.join(','));
|
|
727
798
|
|
|
728
799
|
const query = queryParams.toString();
|
|
729
800
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}${query ? `?${query}` : ''}`);
|
|
730
|
-
|
|
801
|
+
const payload = {
|
|
802
|
+
statusCode: result?.statusCode,
|
|
803
|
+
success: result?.success,
|
|
804
|
+
tableName,
|
|
805
|
+
fields: selectedFields,
|
|
806
|
+
limit: limit || 10,
|
|
807
|
+
minimalDefaultApplied: !(fields && fields.length > 0),
|
|
808
|
+
meta: result?.meta,
|
|
809
|
+
data: result?.data || [],
|
|
810
|
+
detailHint: fields && fields.length > 0
|
|
811
|
+
? undefined
|
|
812
|
+
: 'Only the primary key was returned because fields was omitted. Re-run query_table with explicit fields for details, or use inspect_table to find valid field names.',
|
|
813
|
+
};
|
|
814
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
731
815
|
});
|
|
732
816
|
|
|
733
817
|
server.tool(
|
|
@@ -780,26 +864,50 @@ server.tool(
|
|
|
780
864
|
tableName: z.string().describe('Table name'),
|
|
781
865
|
id: z.string().optional().describe('Record ID'),
|
|
782
866
|
filter: z.string().optional().describe('Filter as JSON string to find by'),
|
|
867
|
+
fields: z.array(z.string()).optional().describe('Fields to select. If omitted, returns only the primary key.'),
|
|
783
868
|
},
|
|
784
|
-
async ({ tableName, id, filter }) => {
|
|
869
|
+
async ({ tableName, id, filter, fields }) => {
|
|
785
870
|
validateTableName(tableName);
|
|
871
|
+
const primaryKey = await getPrimaryFieldName(tableName);
|
|
872
|
+
const selectedFields = fields && fields.length > 0 ? fields : [primaryKey];
|
|
786
873
|
if (id) {
|
|
787
874
|
// Enfyra route engine does not register GET /<table>/:id (only PATCH/DELETE use /:id). Use list + filter.
|
|
788
|
-
const filterObj = JSON.stringify({
|
|
875
|
+
const filterObj = JSON.stringify({ [primaryKey]: { _eq: id } });
|
|
876
|
+
const queryParams = new URLSearchParams({
|
|
877
|
+
filter: filterObj,
|
|
878
|
+
limit: '1',
|
|
879
|
+
fields: selectedFields.join(','),
|
|
880
|
+
});
|
|
789
881
|
const result = await fetchAPI(
|
|
790
882
|
ENFYRA_API_URL,
|
|
791
|
-
`/${tableName}
|
|
883
|
+
`/${tableName}?${queryParams.toString()}`,
|
|
792
884
|
);
|
|
793
885
|
const one = result.data?.[0] ?? null;
|
|
794
|
-
return { content: [{ type: 'text', text: JSON.stringify(
|
|
886
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
887
|
+
tableName,
|
|
888
|
+
primaryKey,
|
|
889
|
+
fields: selectedFields,
|
|
890
|
+
data: one,
|
|
891
|
+
detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
|
|
892
|
+
}, null, 2) }] };
|
|
795
893
|
}
|
|
796
894
|
if (!filter) throw new Error('Provide id or filter');
|
|
797
895
|
validateFilter(filter);
|
|
896
|
+
const queryParams = new URLSearchParams({
|
|
897
|
+
filter,
|
|
898
|
+
limit: '1',
|
|
899
|
+
fields: selectedFields.join(','),
|
|
900
|
+
});
|
|
798
901
|
const result = await fetchAPI(
|
|
799
902
|
ENFYRA_API_URL,
|
|
800
|
-
`/${tableName}
|
|
903
|
+
`/${tableName}?${queryParams.toString()}`,
|
|
801
904
|
);
|
|
802
|
-
return { content: [{ type: 'text', text: JSON.stringify(
|
|
905
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
906
|
+
tableName,
|
|
907
|
+
fields: selectedFields,
|
|
908
|
+
data: result.data?.[0] || null,
|
|
909
|
+
detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
|
|
910
|
+
}, null, 2) }] };
|
|
803
911
|
},
|
|
804
912
|
);
|
|
805
913
|
|
|
@@ -813,7 +921,7 @@ server.tool('create_record', 'Create a new record in any table', {
|
|
|
813
921
|
}, async ({ tableName, data }) => {
|
|
814
922
|
validateTableName(tableName);
|
|
815
923
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}`, { method: 'POST', body: data });
|
|
816
|
-
return { content: [{ type: 'text', text:
|
|
924
|
+
return { content: [{ type: 'text', text: JSON.stringify(summarizeMutationResult(result, 'created', tableName), null, 2) }] };
|
|
817
925
|
});
|
|
818
926
|
|
|
819
927
|
server.tool('update_record', 'Update an existing record by ID using PATCH', {
|
|
@@ -823,7 +931,7 @@ server.tool('update_record', 'Update an existing record by ID using PATCH', {
|
|
|
823
931
|
}, async ({ tableName, id, data }) => {
|
|
824
932
|
validateTableName(tableName);
|
|
825
933
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'PATCH', body: data });
|
|
826
|
-
return { content: [{ type: 'text', text:
|
|
934
|
+
return { content: [{ type: 'text', text: JSON.stringify(summarizeMutationResult(result, 'updated', tableName), null, 2) }] };
|
|
827
935
|
});
|
|
828
936
|
|
|
829
937
|
server.tool('delete_record', 'Delete a record by ID', {
|
|
@@ -832,7 +940,13 @@ server.tool('delete_record', 'Delete a record by ID', {
|
|
|
832
940
|
}, async ({ tableName, id }) => {
|
|
833
941
|
validateTableName(tableName);
|
|
834
942
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'DELETE' });
|
|
835
|
-
return { content: [{ type: 'text', text:
|
|
943
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
944
|
+
action: 'deleted',
|
|
945
|
+
tableName,
|
|
946
|
+
id,
|
|
947
|
+
statusCode: result?.statusCode,
|
|
948
|
+
success: result?.success,
|
|
949
|
+
}, null, 2) }] };
|
|
836
950
|
});
|
|
837
951
|
|
|
838
952
|
server.tool(
|
|
@@ -987,7 +1101,7 @@ function enrichRoute(route, state) {
|
|
|
987
1101
|
.map((item) => pickCodeSummary({
|
|
988
1102
|
...item,
|
|
989
1103
|
method: item.method ? { ...item.method, method: state.methodIdNameMap[String(getId(item.method))] || item.method.method || null } : item.method,
|
|
990
|
-
}, '
|
|
1104
|
+
}, 'sourceCode'));
|
|
991
1105
|
const routePreHooks = withMethodNames(
|
|
992
1106
|
state.preHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
|
|
993
1107
|
state.methodIdNameMap,
|
|
@@ -1138,7 +1252,7 @@ server.tool(
|
|
|
1138
1252
|
relations: table.relations?.map((relation) => ({ propertyName: relation.propertyName, description: relation.description })),
|
|
1139
1253
|
}));
|
|
1140
1254
|
const routeMatches = state.routes.filter((route) => matchesText(route));
|
|
1141
|
-
const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, '
|
|
1255
|
+
const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, 'sourceCode'));
|
|
1142
1256
|
const preHookMatches = state.preHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
|
|
1143
1257
|
const postHookMatches = state.postHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
|
|
1144
1258
|
const guardMatches = state.guards.filter((guard) => matchesText(guard));
|
|
@@ -1234,26 +1348,55 @@ server.tool(
|
|
|
1234
1348
|
},
|
|
1235
1349
|
);
|
|
1236
1350
|
|
|
1237
|
-
server.tool('get_all_routes', 'List
|
|
1351
|
+
server.tool('get_all_routes', 'List route definitions with minimal fields. Call inspect_route for handlers/hooks/permissions detail.', {
|
|
1238
1352
|
includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
|
|
1239
|
-
|
|
1353
|
+
search: z.string().optional().describe('Optional path or table substring filter. Use this before creating a route to check duplicates.'),
|
|
1354
|
+
limit: z.number().optional().describe('Maximum routes returned after search. Default 50 to keep response small.'),
|
|
1355
|
+
}, async ({ includeDisabled, search, limit }) => {
|
|
1240
1356
|
const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1357
|
+
const queryParams = new URLSearchParams({
|
|
1358
|
+
filter: JSON.stringify(filter),
|
|
1359
|
+
fields: 'id,path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled',
|
|
1360
|
+
limit: '1000',
|
|
1361
|
+
});
|
|
1362
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/route_definition?${queryParams.toString()}`);
|
|
1363
|
+
const routeLimit = limit || 50;
|
|
1364
|
+
const q = search ? search.toLowerCase() : null;
|
|
1365
|
+
const allRoutes = summarizeRoutes(result);
|
|
1366
|
+
const matchedRoutes = q
|
|
1367
|
+
? allRoutes.filter((route) => JSON.stringify({
|
|
1368
|
+
path: route.path,
|
|
1369
|
+
mainTable: route.mainTable,
|
|
1370
|
+
}).toLowerCase().includes(q))
|
|
1371
|
+
: allRoutes;
|
|
1372
|
+
const payload = {
|
|
1373
|
+
statusCode: result?.statusCode,
|
|
1374
|
+
success: result?.success,
|
|
1375
|
+
totalRouteCount: allRoutes.length,
|
|
1376
|
+
matchedRouteCount: matchedRoutes.length,
|
|
1377
|
+
returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
|
|
1378
|
+
search: search || null,
|
|
1379
|
+
routes: matchedRoutes.slice(0, routeLimit),
|
|
1380
|
+
detailHint: matchedRoutes.length > routeLimit
|
|
1381
|
+
? `Response truncated to ${routeLimit} routes. Re-run with search or a higher limit, then inspect_route({ path }) for details.`
|
|
1382
|
+
: 'Use inspect_route({ path }) or inspect_route({ routeId }) for handlers, hooks, permissions, and guards.',
|
|
1383
|
+
};
|
|
1384
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1243
1385
|
});
|
|
1244
1386
|
|
|
1245
1387
|
server.tool(
|
|
1246
1388
|
'create_route',
|
|
1247
1389
|
[
|
|
1248
|
-
'**Use this when the user wants a new REST API route or path** — not `create_table`.
|
|
1249
|
-
'
|
|
1390
|
+
'**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
|
|
1391
|
+
'`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/cloud/admin/hosts`, `/auth/login`, or any custom path.',
|
|
1392
|
+
'Do NOT create a new table_definition only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
|
|
1250
1393
|
'availableMethods = which REST verbs the route responds to. publishedMethods = which REST verbs are public (no auth). GraphQL is enabled separately through gql_definition/update_table graphqlEnabled.',
|
|
1251
1394
|
'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
|
|
1252
|
-
'Flow:
|
|
1395
|
+
'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
|
|
1253
1396
|
].join(' '),
|
|
1254
1397
|
{
|
|
1255
1398
|
path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
|
|
1256
|
-
mainTableId: z.union([z.string(), z.number()]).describe('
|
|
1399
|
+
mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
|
|
1257
1400
|
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE']))
|
|
1258
1401
|
.describe('HTTP methods this route supports (availableMethods). Common: ["GET","POST","PATCH","DELETE"]'),
|
|
1259
1402
|
publishedMethods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
|
|
@@ -1266,12 +1409,15 @@ server.tool(
|
|
|
1266
1409
|
|
|
1267
1410
|
const body = {
|
|
1268
1411
|
path: routePath.startsWith('/') ? routePath : '/' + routePath,
|
|
1269
|
-
mainTable: { id: mainTableId },
|
|
1270
1412
|
isEnabled,
|
|
1271
1413
|
description,
|
|
1272
1414
|
availableMethods: resolveMethodIds(methodMap, methods),
|
|
1273
1415
|
};
|
|
1274
1416
|
|
|
1417
|
+
if (mainTableId !== undefined && mainTableId !== null) {
|
|
1418
|
+
body.mainTable = { id: mainTableId };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1275
1421
|
if (publishedMethods && publishedMethods.length > 0) {
|
|
1276
1422
|
body.publishedMethods = resolveMethodIds(methodMap, publishedMethods);
|
|
1277
1423
|
}
|
|
@@ -1284,7 +1430,18 @@ server.tool(
|
|
|
1284
1430
|
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
1285
1431
|
|
|
1286
1432
|
const created = firstDataRecord(result);
|
|
1287
|
-
return { content: [{ type: 'text', text:
|
|
1433
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1434
|
+
action: 'created',
|
|
1435
|
+
route: {
|
|
1436
|
+
id: getId(created),
|
|
1437
|
+
path: created?.path,
|
|
1438
|
+
mainTableId: mainTableId ?? null,
|
|
1439
|
+
availableMethods: methods,
|
|
1440
|
+
publishedMethods: publishedMethods || [],
|
|
1441
|
+
},
|
|
1442
|
+
routesReloaded: true,
|
|
1443
|
+
next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET"|"POST"|"PATCH"|"DELETE", sourceCode }) for custom code.`,
|
|
1444
|
+
}, null, 2) }] };
|
|
1288
1445
|
},
|
|
1289
1446
|
);
|
|
1290
1447
|
|
|
@@ -1293,38 +1450,56 @@ server.tool(
|
|
|
1293
1450
|
[
|
|
1294
1451
|
'Create a handler for a route+method. One handler per (route, method) pair.',
|
|
1295
1452
|
'Attach to the route the user cares about (`get_all_routes`): typically a path from `create_route`, not a spurious table created only for handlers.',
|
|
1453
|
+
'Use sourceCode, not logic/name. Enfyra compiles sourceCode into compiledCode; do not send compiledCode.',
|
|
1296
1454
|
'Handler code runs inside a sandbox with $ctx. Use macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503, @SOCKET, @PKGS, @LOGS, @SHARE.',
|
|
1297
1455
|
'Or use $ctx directly: $ctx.$body, $ctx.$repos.main.find(), $ctx.$helpers.$bcrypt.hash(), etc.',
|
|
1298
1456
|
'require("pkg") works for installed Server packages. console.log() writes to $share.$logs.',
|
|
1299
1457
|
].join(' '),
|
|
1300
1458
|
{
|
|
1301
1459
|
routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
|
|
1302
|
-
|
|
1303
|
-
.describe('
|
|
1304
|
-
|
|
1460
|
+
method: z.enum(['GET', 'POST', 'PATCH', 'DELETE']).optional()
|
|
1461
|
+
.describe('Single method to create. Prefer this for one handler.'),
|
|
1462
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
|
|
1463
|
+
.describe('Batch create multiple handlers. Use only when the same sourceCode applies to every method.'),
|
|
1464
|
+
sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic.'),
|
|
1465
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
|
|
1305
1466
|
timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
|
|
1306
1467
|
},
|
|
1307
|
-
async ({ routeId, methods,
|
|
1468
|
+
async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout }) => {
|
|
1469
|
+
const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
|
|
1470
|
+
if (methodNames.length === 0) throw new Error('Provide method or methods');
|
|
1308
1471
|
const methodMap = await getMethodMap();
|
|
1309
1472
|
const results = [];
|
|
1310
1473
|
|
|
1311
|
-
for (const
|
|
1312
|
-
const methodId = methodMap[
|
|
1313
|
-
if (!methodId) throw new Error(`Unknown method: ${
|
|
1474
|
+
for (const methodName of methodNames) {
|
|
1475
|
+
const methodId = methodMap[methodName.toUpperCase()];
|
|
1476
|
+
if (!methodId) throw new Error(`Unknown method: ${methodName}. Valid: ${Object.keys(methodMap).join(', ')}`);
|
|
1314
1477
|
|
|
1315
|
-
const body = { route: { id: routeId }, method: { id: methodId },
|
|
1478
|
+
const body = { route: { id: routeId }, method: { id: methodId }, sourceCode, scriptLanguage };
|
|
1316
1479
|
if (timeout) body.timeout = timeout;
|
|
1317
1480
|
|
|
1318
1481
|
const result = await fetchAPI(ENFYRA_API_URL, '/route_handler_definition', {
|
|
1319
1482
|
method: 'POST',
|
|
1320
1483
|
body: JSON.stringify(body),
|
|
1321
1484
|
});
|
|
1322
|
-
|
|
1485
|
+
const created = firstDataRecord(result);
|
|
1486
|
+
results.push({
|
|
1487
|
+
id: getId(created),
|
|
1488
|
+
routeId,
|
|
1489
|
+
method: methodName,
|
|
1490
|
+
scriptLanguage,
|
|
1491
|
+
timeout: created?.timeout ?? timeout ?? null,
|
|
1492
|
+
});
|
|
1323
1493
|
}
|
|
1324
1494
|
|
|
1325
1495
|
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
1326
1496
|
|
|
1327
|
-
return { content: [{ type: 'text', text:
|
|
1497
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1498
|
+
action: 'created',
|
|
1499
|
+
handlers: results,
|
|
1500
|
+
routesReloaded: true,
|
|
1501
|
+
detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
|
|
1502
|
+
}, null, 2) }] };
|
|
1328
1503
|
},
|
|
1329
1504
|
);
|
|
1330
1505
|
|