@enfyra/mcp-server 0.0.51 → 0.0.53

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.51",
3
+ "version": "0.0.53",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -184,12 +184,52 @@ window.location.href = url.toString()`,
184
184
  'For chat-list UX, default to a boolean unread dot instead of exact counts.',
185
185
  ],
186
186
  },
187
+ {
188
+ name: 'Add server-owned user verification fields',
189
+ code: `create_column({
190
+ tableId: "<user_definition_table_id>",
191
+ name: "emailVerifiedAt",
192
+ type: "datetime",
193
+ isNullable: true,
194
+ isPublished: true,
195
+ description: "When the user's email address was verified."
196
+ })
197
+
198
+ create_column({
199
+ tableId: "<user_definition_table_id>",
200
+ name: "emailVerificationStatus",
201
+ type: "varchar",
202
+ isNullable: false,
203
+ defaultValue: "pending",
204
+ isPublished: true,
205
+ description: "Email verification state controlled by server hooks."
206
+ })`,
207
+ notes: [
208
+ 'Run schema-changing calls sequentially. Do not parallelize create_column calls.',
209
+ 'create_column fetches table_definition and patches only real persisted columns with id/_id; generated metadata projections such as createdAt, updatedAt, or relation FK display fields are skipped.',
210
+ 'Use hooks or field permissions to prevent clients from updating server-owned fields.',
211
+ ],
212
+ },
187
213
  ],
188
214
  },
189
215
  'queries-deep': {
190
216
  title: 'REST queries, filters, meta counts, and deep relation fetches',
191
217
  useWhen: 'Use when fetching records, filtering by relations, loading nested data, or counting efficiently.',
192
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
+ },
193
233
  {
194
234
  name: 'List current user conversations through RLS',
195
235
  code: `GET /enfyra/chat_conversation?fields=id,kind,title,lastMessage.id,lastMessage.text,lastMessage.createdAt&limit=0`,
@@ -243,6 +283,23 @@ window.location.href = url.toString()`,
243
283
  title: 'Custom handlers, pre-hooks, post-hooks, and script macros',
244
284
  useWhen: 'Use when writing Enfyra dynamic JavaScript for REST behavior.',
245
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
+ },
246
303
  {
247
304
  name: 'Custom register handler',
248
305
  code: `const email = @BODY.email
@@ -289,19 +346,49 @@ const scope = {
289
346
  },
290
347
  {
291
348
  name: 'Pre-hook encrypted field normalization',
292
- code: `const value = @BODY.api_token_encrypted
349
+ code: `create_pre_hook({
350
+ routeId: "<route_id>",
351
+ name: "encrypt_api_token",
352
+ methods: ["POST", "PATCH"],
353
+ priority: 0,
354
+ code: \`const value = @BODY.api_token_encrypted
293
355
  if (value && value.slice(0, 7) !== "enc:v1:") {
294
356
  @BODY.api_token_encrypted = @HELPERS.$encrypt.encrypt(value)
295
- }`,
357
+ }\`
358
+ })`,
296
359
  notes: [
360
+ 'MCP create_pre_hook accepts code as the tool argument, then persists it to Enfyra as sourceCode with scriptLanguage.',
361
+ 'Do not call raw create_record with a code field for pre_hook_definition or post_hook_definition; backend CRUD rejects code.',
297
362
  'Use Enfyra pre-hooks for request-body normalization before canonical CRUD persists the record.',
298
363
  'Do not implement encrypted field normalization as a Knex/database hook.',
299
364
  'Use $encrypt for encryption and $ssh.generateKeyPair for SSH key generation; do not use $secrets.',
300
365
  ],
301
366
  },
367
+ {
368
+ name: 'Pre-hook strips protected body fields silently',
369
+ code: `create_pre_hook({
370
+ routeId: "<user_definition_patch_route_id>",
371
+ name: "strip_email_verification_fields",
372
+ methods: ["PATCH"],
373
+ priority: -10,
374
+ code: \`delete @BODY.emailVerifiedAt
375
+ delete @BODY.emailVerificationStatus
376
+ delete @BODY.emailVerificationSentAt\`
377
+ })`,
378
+ notes: [
379
+ 'Use this pattern when clients may send protected user fields through /me or user_definition PATCH.',
380
+ 'Strip fields instead of throwing when the product wants a permissive client contract with server-owned fields.',
381
+ 'Use native macros such as @BODY instead of raw $ctx when a macro exists.',
382
+ ],
383
+ },
302
384
  {
303
385
  name: 'Post-hook response shaping',
304
- code: `if (@ERROR) {
386
+ code: `create_post_hook({
387
+ routeId: "<route_id>",
388
+ name: "shape_display_title",
389
+ methods: ["GET"],
390
+ priority: 0,
391
+ code: \`if (@ERROR) {
305
392
  @LOGS("Request failed", @ERROR.message)
306
393
  return
307
394
  }
@@ -311,8 +398,10 @@ if (row) {
311
398
  row.displayTitle = row.title || row.email || String(row.id)
312
399
  }
313
400
 
314
- return @DATA`,
401
+ return @DATA\`
402
+ })`,
315
403
  notes: [
404
+ 'MCP create_post_hook accepts code as the tool argument, then persists sourceCode/scriptLanguage to Enfyra.',
316
405
  'Post-hooks run after success and error paths.',
317
406
  'Return non-undefined only when replacing the response body.',
318
407
  ],
@@ -650,6 +739,58 @@ create_extension({
650
739
  'After saving, open eApp tabs should update through the server/eApp realtime reload contract; do not tell the user to refresh unless that contract is proven broken.',
651
740
  ],
652
741
  },
742
+ {
743
+ name: 'Page header and action button variants',
744
+ code: `<script setup>
745
+ const { registerPageHeader } = usePageHeaderRegistry()
746
+
747
+ registerPageHeader({
748
+ title: 'Host detail',
749
+ description: 'Provider state, capacity, projects, and reconciliation status.',
750
+ leadingIcon: 'lucide:server',
751
+ gradient: 'cyan',
752
+ variant: 'minimal'
753
+ })
754
+
755
+ useHeaderActionRegistry([
756
+ {
757
+ id: 'back-to-hosts',
758
+ label: 'Hosts',
759
+ icon: 'lucide:arrow-left',
760
+ color: 'neutral',
761
+ variant: 'ghost',
762
+ order: 0,
763
+ onClick: () => navigateTo('/cloud/hosts')
764
+ },
765
+ {
766
+ id: 'run-host-check',
767
+ label: 'Run check',
768
+ icon: 'lucide:scan-search',
769
+ color: 'neutral',
770
+ variant: 'outline',
771
+ order: 1,
772
+ permission: { or: [{ route: '/cloud/admin/hosts/reconcile', methods: ['POST'] }] },
773
+ onClick: runCheck
774
+ },
775
+ {
776
+ id: 'refresh-host',
777
+ label: 'Refresh',
778
+ icon: 'lucide:refresh-cw',
779
+ color: 'primary',
780
+ variant: 'solid',
781
+ order: 2,
782
+ onClick: refresh
783
+ }
784
+ ])
785
+ </script>`,
786
+ notes: [
787
+ 'Use PageHeader for the title strip; do not render a duplicate header inside extension body.',
788
+ 'Back/navigation actions should be neutral ghost so they read as navigation, not a primary operation.',
789
+ 'Visible secondary operations should be neutral outline; soft is only for low-emphasis chrome actions.',
790
+ 'The main page action should be primary solid.',
791
+ 'Do not choose soft only because it looks acceptable in dark mode; light mode must remain clear too.',
792
+ ],
793
+ },
653
794
  {
654
795
  name: 'Debug menu or extension changes that do not appear in open eApp tabs',
655
796
  code: `// Server side: menu_definition and extension_definition are runtime UI definitions.
@@ -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.',
@@ -74,6 +76,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
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
78
  '- **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).',
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,6 +106,7 @@ 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)',
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.',
106
110
  '- Relation fields (mainTable, publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
107
111
  ' - **Many-to-one:** `"mainTable": {"id": 4}` (single object with id)',
108
112
  ' - **One-to-many / many-to-many:** `"publishedMethods": [{"id": 1}, {"id": 2}]` (array of objects with id)',
@@ -134,6 +138,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
134
138
  '- 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
139
  '- ASV exposes `$helpers.$encrypt.encrypt/decrypt` for encrypted strings and `$helpers.$ssh.generateKeyPair` for SSH keys. Do not generate `$helpers.$secrets` usage.',
136
140
  '- 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.',
141
+ '- 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.',
142
+ '- 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.',
137
143
  '- 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.',
138
144
  '- 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.',
139
145
  '- Before saving generated script code, validate it with `POST /admin/script/validate` when available. It compiles with the server kernel and parses the executable async body without running side effects. Enfyra App `FormCodeEditor` also exposes a `Validate` action for this endpoint; use it before save/run when editing through the UI. If unavailable, use `run_admin_test`/`test_flow_step` as the closest validation path before saving.',
@@ -191,10 +197,11 @@ export function buildMcpServerInstructions(apiBaseUrl) {
191
197
  '### System tables — which have REST routes',
192
198
  '- **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.',
193
199
  '- **`column_definition` and `session_definition` have NO route** — do NOT call `query_table("column_definition", …)` or `query_table("session_definition", …)`. They will 404.',
200
+ '- 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.',
194
201
  '- 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.',
195
202
  '- **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`.',
196
203
  '- **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.',
197
- '- 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`.',
204
+ '- 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.',
198
205
  '- 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.',
199
206
  '',
200
207
  '### Body validation & column rules',
@@ -294,6 +301,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
294
301
  '- **PageHeader is mandatory for page extensions:** eApp already renders `CommonPageHeader` from `usePageHeaderRegistry()` in the app shell. Page extensions must call `const { registerPageHeader } = usePageHeaderRegistry()` and register app-level context such as `{ title, description, leadingIcon, gradient, variant }` instead of rendering their own top `<header>` inside extension content. Use `variant: "minimal"` for operational/admin detail pages unless the page intentionally needs a larger title strip.',
295
302
  '- **Do not misuse PageHeader stats:** `PageHeader.stats` renders prominent stat cards inside the shell header. Do not put normal operational KPIs, host capacity, billing totals, or detail metrics there by default; keep those as body cards/tables where the operator can scan them with the page content. Only use PageHeader stats for a deliberately compact overview page where the stats are truly header-level context.',
296
303
  '- **Page actions belong in registries:** Move page-level buttons into `useHeaderActionRegistry` or `useSubHeaderActionRegistry`; keep the extension body for operational content only. Sensitive registry actions must include a `permission` condition, for example `{ id: "create", label: "Create project", permission: { and: [{ route: "/cloud_projects", methods: ["POST"] }] }, onClick }`.',
304
+ '- **Header action button variants:** choose the button variant by intent. Use `color: "primary", variant: "solid"` for the main page action. Use `color: "neutral", variant: "ghost"` for back/navigation actions and `color: "neutral", variant: "outline"` for visible secondary actions. `variant: "soft"` is only for low-emphasis secondary/chrome actions; do not use soft for critical or primary header actions just because it looks acceptable in dark mode.',
297
305
  '- **Extension navigation:** prefer `NuxtLink` or Nuxt UI components with `:to` for visible navigation links and drill-down cards/buttons. Use `navigateTo(...)` only for imperative navigation after submit, confirm, mutation, or another side effect.',
298
306
  '- **Extension runtime scope:** eApp exposes Vue APIs and injected Nuxt/Enfyra composables both to script global scope and Vue app `globalProperties`. Template expressions may call injected helpers directly, for example after a save handler can call `navigateTo("/data/cloud_projects")`, because Vue compiles template helpers to `_ctx.*`.',
299
307
  '- **Extension CSS affects shell utility ordering:** dynamic extension CSS is injected after the app shell CSS. Shell/page-header code must not put conflicting plain Tailwind utilities on the same element, such as `flex-col` plus `flex-row`, `items-start` plus `items-center`, or `text-left` plus `text-center`. Choose one mutually exclusive class per state; otherwise extension CSS can change which utility wins and shift shell layout.',
@@ -380,7 +388,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
380
388
  '',
381
389
  '#### Important patterns:',
382
390
  '- **useApi:** Must call `execute()` — does NOT auto-run. Supports batch operations with `ids` or `files` options.',
383
- '- **Header actions:** `useHeaderActionRegistry([{ id: \'refresh\', label: \'Refresh\', onClick: fn, color: \'primary\', icon: \'lucide:refresh\', order: 0 }])`',
391
+ '- **Header actions:** `useHeaderActionRegistry([{ id: \'back\', label: \'Hosts\', icon: \'lucide:arrow-left\', color: \'neutral\', variant: \'ghost\', order: 0, onClick: goBack }, { id: \'refresh\', label: \'Refresh\', icon: \'lucide:refresh-cw\', color: \'primary\', variant: \'solid\', order: 1, onClick: refresh }])`',
384
392
  '- **Schema:** Call `fetchSchema()` first, then use `definition.value`, `editableFields.value`, `getField(\'fieldName\')`.',
385
393
  '- **Permissions:** Use `checkPermissionCondition({ or: [{ route: \'/posts\', methods: [\'GET\'] }] })` for complex rules. In templates, wrap sensitive controls with `<PermissionGate :condition="{ and: [{ route: \'/admin/action\', methods: [\'POST\'] }] }">...</PermissionGate>` instead of only disabling them visually.',
386
394
  '- **After menu/extension create/update:** open eApp tabs should update through the `$system:reload` contract. Do not tell the user to press F5 unless you have verified the natural reload event failed or the server/eApp version does not support menu/extension reload yet.',
@@ -84,11 +84,21 @@ function normalizeRelationForTablePatch(relation) {
84
84
  return normalized;
85
85
  }
86
86
 
87
+ function getId(record) {
88
+ return record?.id ?? record?._id ?? null;
89
+ }
90
+
87
91
  function normalizeColumnForTablePatch(column) {
88
92
  const { table, ...rest } = column;
89
93
  return rest;
90
94
  }
91
95
 
96
+ function getPatchableColumns(columns) {
97
+ return (columns || [])
98
+ .filter((column) => getId(column) !== null)
99
+ .map(normalizeColumnForTablePatch);
100
+ }
101
+
92
102
  function buildColumnDefinition({
93
103
  name,
94
104
  type,
@@ -127,7 +137,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
127
137
  return { content: [{ type: 'text', text: `Error: Table with ID ${args.tableId} not found.` }] };
128
138
  }
129
139
 
130
- const existingColumns = (tableData.columns || []).map(normalizeColumnForTablePatch);
140
+ const existingColumns = getPatchableColumns(tableData.columns);
131
141
  const newCol = buildColumnDefinition(args);
132
142
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, args.tableId, { columns: [...existingColumns, newCol] });
133
143
 
@@ -161,7 +171,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
161
171
  }
162
172
 
163
173
  const columns = (tableData.columns || [])
164
- .filter(col => String(col.id) !== String(columnId))
174
+ .filter((col) => getId(col) !== null)
175
+ .filter(col => String(getId(col)) !== String(columnId))
165
176
  .map(normalizeColumnForTablePatch);
166
177
 
167
178
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
@@ -372,7 +383,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
372
383
  [
373
384
  'Add a column to an existing table via PATCH /table_definition/{tableId}.',
374
385
  'Columns are managed through cascade with table_definition — there is NO direct /column_definition endpoint.',
375
- 'This tool fetches existing columns, appends the new one, and PATCHes the table.',
386
+ 'This tool fetches existing columns, keeps only persisted column rows with id/_id, appends the new one, and PATCHes the table.',
387
+ 'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are not valid cascade rows and are skipped.',
376
388
  'Run schema changes sequentially — migration locks DB per operation.',
377
389
  ].join(' '),
378
390
  {
@@ -386,6 +398,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
386
398
  [
387
399
  'Alias for create_column. Add a column to an existing table through the canonical table_definition cascade.',
388
400
  '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.',
389
402
  'Run schema changes sequentially — migration locks DB per operation.',
390
403
  ].join(' '),
391
404
  columnCreateSchema,
@@ -398,7 +411,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
398
411
  'update_column',
399
412
  [
400
413
  'Update an existing column on a table via PATCH /table_definition/{tableId}.',
401
- 'Fetches all columns, modifies the target column, and PATCHes the table.',
414
+ 'Fetches table columns, keeps only persisted rows with id/_id, modifies the target column, and PATCHes the table.',
415
+ 'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are skipped.',
402
416
  'Run schema changes sequentially — migration locks DB per operation.',
403
417
  ].join(' '),
404
418
  {
@@ -418,9 +432,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
418
432
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
419
433
  }
420
434
 
421
- const columns = (tableData.columns || []).map(col => {
435
+ const columns = (tableData.columns || []).filter((col) => getId(col) !== null).map(col => {
422
436
  const rest = normalizeColumnForTablePatch(col);
423
- if (String(col.id) === String(columnId)) {
437
+ if (String(getId(col)) === String(columnId)) {
424
438
  if (name !== undefined) rest.name = name;
425
439
  if (type !== undefined) rest.type = type;
426
440
  if (isNullable !== undefined) rest.isNullable = isNullable;
@@ -446,7 +460,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
446
460
  'delete_column',
447
461
  [
448
462
  'Delete a column from a table via PATCH /table_definition/{tableId}.',
449
- 'Fetches all columns, removes the target, and PATCHes the table.',
463
+ 'Fetches table columns, keeps only persisted rows with id/_id, removes the target, and PATCHes the table.',
450
464
  'The physical column is dropped from the database. System columns (id, createdAt, updatedAt) cannot be deleted.',
451
465
  'Run schema changes sequentially — migration locks DB per operation.',
452
466
  ].join(' '),
@@ -461,6 +475,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
461
475
  [
462
476
  'Alias for delete_column. Remove a column through the canonical table_definition cascade.',
463
477
  '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.',
464
479
  'Run schema changes sequentially — migration locks DB per operation.',
465
480
  ].join(' '),
466
481
  columnDeleteSchema,
@@ -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,12 +1348,40 @@ 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(
@@ -1283,7 +1425,19 @@ server.tool(
1283
1425
 
1284
1426
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1285
1427
 
1286
- return { content: [{ type: 'text', text: `Route created (ID: ${result.id}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1428
+ const created = firstDataRecord(result);
1429
+ return { content: [{ type: 'text', text: JSON.stringify({
1430
+ action: 'created',
1431
+ route: {
1432
+ id: getId(created),
1433
+ path: created?.path,
1434
+ mainTableId,
1435
+ availableMethods: methods,
1436
+ publishedMethods: publishedMethods || [],
1437
+ },
1438
+ routesReloaded: true,
1439
+ next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET"|"POST"|"PATCH"|"DELETE", sourceCode }) for custom code.`,
1440
+ }, null, 2) }] };
1287
1441
  },
1288
1442
  );
1289
1443
 
@@ -1292,38 +1446,56 @@ server.tool(
1292
1446
  [
1293
1447
  'Create a handler for a route+method. One handler per (route, method) pair.',
1294
1448
  '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.',
1449
+ 'Use sourceCode, not logic/name. Enfyra compiles sourceCode into compiledCode; do not send compiledCode.',
1295
1450
  'Handler code runs inside a sandbox with $ctx. Use macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503, @SOCKET, @PKGS, @LOGS, @SHARE.',
1296
1451
  'Or use $ctx directly: $ctx.$body, $ctx.$repos.main.find(), $ctx.$helpers.$bcrypt.hash(), etc.',
1297
1452
  'require("pkg") works for installed Server packages. console.log() writes to $share.$logs.',
1298
1453
  ].join(' '),
1299
1454
  {
1300
1455
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1301
- methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE']))
1302
- .describe('Methods to create handlers for. Creates one handler per method.'),
1303
- logic: z.string().describe('Handler JavaScript code'),
1456
+ method: z.enum(['GET', 'POST', 'PATCH', 'DELETE']).optional()
1457
+ .describe('Single method to create. Prefer this for one handler.'),
1458
+ methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1459
+ .describe('Batch create multiple handlers. Use only when the same sourceCode applies to every method.'),
1460
+ sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic.'),
1461
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
1304
1462
  timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
1305
1463
  },
1306
- async ({ routeId, methods, logic, timeout }) => {
1464
+ async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout }) => {
1465
+ const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
1466
+ if (methodNames.length === 0) throw new Error('Provide method or methods');
1307
1467
  const methodMap = await getMethodMap();
1308
1468
  const results = [];
1309
1469
 
1310
- for (const method of methods) {
1311
- const methodId = methodMap[method.toUpperCase()];
1312
- if (!methodId) throw new Error(`Unknown method: ${method}. Valid: ${Object.keys(methodMap).join(', ')}`);
1470
+ for (const methodName of methodNames) {
1471
+ const methodId = methodMap[methodName.toUpperCase()];
1472
+ if (!methodId) throw new Error(`Unknown method: ${methodName}. Valid: ${Object.keys(methodMap).join(', ')}`);
1313
1473
 
1314
- const body = { route: { id: routeId }, method: { id: methodId }, logic };
1474
+ const body = { route: { id: routeId }, method: { id: methodId }, sourceCode, scriptLanguage };
1315
1475
  if (timeout) body.timeout = timeout;
1316
1476
 
1317
1477
  const result = await fetchAPI(ENFYRA_API_URL, '/route_handler_definition', {
1318
1478
  method: 'POST',
1319
1479
  body: JSON.stringify(body),
1320
1480
  });
1321
- results.push(result);
1481
+ const created = firstDataRecord(result);
1482
+ results.push({
1483
+ id: getId(created),
1484
+ routeId,
1485
+ method: methodName,
1486
+ scriptLanguage,
1487
+ timeout: created?.timeout ?? timeout ?? null,
1488
+ });
1322
1489
  }
1323
1490
 
1324
1491
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1325
1492
 
1326
- return { content: [{ type: 'text', text: `Handler(s) created for [${methods.join(', ')}]. Routes reloaded.\n${JSON.stringify(results, null, 2)}` }] };
1493
+ return { content: [{ type: 'text', text: JSON.stringify({
1494
+ action: 'created',
1495
+ handlers: results,
1496
+ routesReloaded: true,
1497
+ detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
1498
+ }, null, 2) }] };
1327
1499
  },
1328
1500
  );
1329
1501
 
@@ -1338,7 +1510,7 @@ server.tool(
1338
1510
  {
1339
1511
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1340
1512
  name: z.string().describe('Hook name (unique per route)'),
1341
- code: z.string().describe('Hook JavaScript code'),
1513
+ code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
1342
1514
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1343
1515
  .describe('Methods this hook applies to. Default: all REST methods.'),
1344
1516
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
@@ -1353,7 +1525,8 @@ server.tool(
1353
1525
  body: JSON.stringify({
1354
1526
  route: { id: routeId },
1355
1527
  name,
1356
- code,
1528
+ sourceCode: code,
1529
+ scriptLanguage: 'javascript',
1357
1530
  methods: resolveMethodIds(methodMap, methodNames),
1358
1531
  priority,
1359
1532
  isEnabled,
@@ -1362,7 +1535,8 @@ server.tool(
1362
1535
 
1363
1536
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1364
1537
 
1365
- return { content: [{ type: 'text', text: `Pre-hook "${name}" created (ID: ${result.id}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1538
+ const created = firstDataRecord(result);
1539
+ return { content: [{ type: 'text', text: `Pre-hook "${name}" created (ID: ${getId(created)}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1366
1540
  },
1367
1541
  );
1368
1542
 
@@ -1377,7 +1551,7 @@ server.tool(
1377
1551
  {
1378
1552
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
1379
1553
  name: z.string().describe('Hook name (unique per route)'),
1380
- code: z.string().describe('Hook JavaScript code'),
1554
+ code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
1381
1555
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
1382
1556
  .describe('Methods this hook applies to. Default: all REST methods.'),
1383
1557
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
@@ -1392,7 +1566,8 @@ server.tool(
1392
1566
  body: JSON.stringify({
1393
1567
  route: { id: routeId },
1394
1568
  name,
1395
- code,
1569
+ sourceCode: code,
1570
+ scriptLanguage: 'javascript',
1396
1571
  methods: resolveMethodIds(methodMap, methodNames),
1397
1572
  priority,
1398
1573
  isEnabled,
@@ -1401,7 +1576,8 @@ server.tool(
1401
1576
 
1402
1577
  await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
1403
1578
 
1404
- return { content: [{ type: 'text', text: `Post-hook "${name}" created (ID: ${result.id}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1579
+ const created = firstDataRecord(result);
1580
+ return { content: [{ type: 'text', text: `Post-hook "${name}" created (ID: ${getId(created)}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
1405
1581
  },
1406
1582
  );
1407
1583
 
@@ -1691,7 +1867,7 @@ server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
1691
1867
  });
1692
1868
 
1693
1869
  server.tool('login', 'Force authentication to Enfyra and get a new access token', {
1694
- apiToken: z.string().optional().describe('API token; preferred for MCP and automation'),
1870
+ apiToken: z.string().optional().describe('API token for MCP and automation'),
1695
1871
  }, async ({ apiToken }) => {
1696
1872
  const token = apiToken || ENFYRA_API_TOKEN;
1697
1873
  if (token) {
@@ -1825,7 +2001,8 @@ server.tool('create_menu', 'Create a menu item in the navigation', {
1825
2001
  body.path = '/' + body.path;
1826
2002
  }
1827
2003
  const result = await fetchAPI(ENFYRA_API_URL, '/menu_definition', { method: 'POST', body: JSON.stringify(body) });
1828
- return { content: [{ type: 'text', text: `Menu created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
2004
+ const created = firstDataRecord(result);
2005
+ return { content: [{ type: 'text', text: `Menu created (ID: ${getId(created)}):\n${JSON.stringify(result, null, 2)}` }] };
1829
2006
  });
1830
2007
 
1831
2008
  server.tool(
@@ -1850,7 +2027,8 @@ server.tool(
1850
2027
  delete body.menuId;
1851
2028
  }
1852
2029
  const result = await fetchAPI(ENFYRA_API_URL, '/extension_definition', { method: 'POST', body: JSON.stringify(body) });
1853
- return { content: [{ type: 'text', text: `Extension created (ID: ${result.id}). Open eApp tabs should update through the realtime reload contract.\n${JSON.stringify(result, null, 2)}` }] };
2030
+ const created = firstDataRecord(result);
2031
+ return { content: [{ type: 'text', text: `Extension created (ID: ${getId(created)}). Open eApp tabs should update through the realtime reload contract.\n${JSON.stringify(result, null, 2)}` }] };
1854
2032
  },
1855
2033
  );
1856
2034