@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.52",
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",
@@ -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`** with **`mainTableId`** of an **existing** table (resolve id via **`get_all_tables`**, **`get_all_metadata`**, or **`get_all_routes`**).',
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). Same underlying table can have **multiple** routes (e.g. default CRUD at `/orders` and custom `/orders/stats` both pointing at `mainTable` orders).',
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
- '- Relation fields (mainTable, publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
107
- ' - **Many-to-one:** `"mainTable": {"id": 4}` (single object with id)',
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
- '- To check which tables are accessible via MCP tools, call `get_all_routes` and look for the route whose `mainTable.id` matches the table you need, or `get_all_metadata` to see all table names.',
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** and **mainTable** before stating a full URL.',
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.',
@@ -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 result = await fetchAPI(ENFYRA_API_URL, `/table_definition?filter=${filter}&limit=1&fields=*,columns.*,relations.*`);
13
- return result?.data?.[0] || result?.[0] || null;
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 columns = (tableData.columns || [])
174
- .filter((col) => getId(col) !== null)
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`** with an existing `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
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 fetches existing columns, keeps only persisted column rows with id/_id, appends the new one, and PATCHes the table.',
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
- 'Skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
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
- 'Fetches table columns, keeps only persisted rows with id/_id, modifies the target column, and PATCHes the table.',
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 columns = (tableData.columns || []).filter((col) => getId(col) !== null).map(col => {
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
- 'Fetches table columns, keeps only persisted rows with id/_id, removes the target, and PATCHes the table.',
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
- 'Skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
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,
@@ -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 on an existing main table, then add handlers/hooks. REST methods are GET/POST/PATCH/DELETE.',
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 all metadata (tables, columns, relations, routes, hooks, etc.) from Enfyra', {}, async () => {
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
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
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
- }, async ({ tableName }) => {
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
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
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 in Enfyra with filters, sorting, and pagination', {
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 (default: 50, max: 500)'),
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
- if (limit) queryParams.set('limit', String(limit));
726
- if (fields) queryParams.set('fields', fields.join(','));
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
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
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({ id: { _eq: id } });
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}?filter=${encodeURIComponent(filterObj)}&limit=1`,
883
+ `/${tableName}?${queryParams.toString()}`,
792
884
  );
793
885
  const one = result.data?.[0] ?? null;
794
- return { content: [{ type: 'text', text: JSON.stringify(one, null, 2) }] };
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}?filter=${encodeURIComponent(filter)}&limit=1`,
903
+ `/${tableName}?${queryParams.toString()}`,
801
904
  );
802
- return { content: [{ type: 'text', text: JSON.stringify(result.data?.[0] || null, null, 2) }] };
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: `Record created:\n${JSON.stringify(result, null, 2)}` }] };
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: `Record updated:\n${JSON.stringify(result, null, 2)}` }] };
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: `Record deleted:\n${JSON.stringify(result, null, 2)}` }] };
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
- }, 'logic'));
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, 'logic'));
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 all route definitions (path, mainTable, handlers, hooks, permissions). Call before create_route to avoid duplicate paths and to pick routeId for hooks/handlers.', {
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
- }, async ({ includeDisabled }) => {
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 result = await fetchAPI(ENFYRA_API_URL, `/route_definition?filter=${encodeURIComponent(JSON.stringify(filter))}&limit=500`);
1242
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
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`. A route links a URL path to an existing table (`mainTableId`) and sets HTTP methods.',
1249
- 'Do NOT create a new table_definition only to expose an endpoint; pick `mainTableId` from existing metadata unless the user explicitly needs new tables/columns.',
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: resolve table id → create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
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('ID of the table_definition this route operates on. The route\'s $repos.main will query this table.'),
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: `Route created (ID: ${getId(created)}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
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
- methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE']))
1303
- .describe('Methods to create handlers for. Creates one handler per method.'),
1304
- logic: z.string().describe('Handler JavaScript code'),
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, logic, timeout }) => {
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 method of methods) {
1312
- const methodId = methodMap[method.toUpperCase()];
1313
- if (!methodId) throw new Error(`Unknown method: ${method}. Valid: ${Object.keys(methodMap).join(', ')}`);
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 }, logic };
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
- results.push(result);
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: `Handler(s) created for [${methods.join(', ')}]. Routes reloaded.\n${JSON.stringify(results, null, 2)}` }] };
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