@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 +1 -1
- package/src/lib/mcp-examples.js +145 -4
- package/src/lib/mcp-instructions.js +10 -2
- package/src/lib/table-tools.js +22 -7
- package/src/mcp-server-entry.mjs +224 -46
package/package.json
CHANGED
package/src/lib/mcp-examples.js
CHANGED
|
@@ -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: `
|
|
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: `
|
|
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: \'
|
|
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.',
|
package/src/lib/table-tools.js
CHANGED
|
@@ -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
|
|
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 =>
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -199,6 +199,31 @@ function summarizeRoutes(routesResult) {
|
|
|
199
199
|
}));
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
function summarizeMetadata(metadata, { search, limit } = {}) {
|
|
203
|
+
const tables = normalizeTables(metadata);
|
|
204
|
+
const q = search ? search.toLowerCase() : null;
|
|
205
|
+
const summarized = tables.map((table) => ({
|
|
206
|
+
id: table.id ?? table._id,
|
|
207
|
+
name: table.name,
|
|
208
|
+
alias: table.alias,
|
|
209
|
+
primaryKey: getPrimaryColumn(table)?.name || null,
|
|
210
|
+
columnCount: (table.columns || []).length,
|
|
211
|
+
relationCount: (table.relations || []).length,
|
|
212
|
+
routeHint: `Use get_table_metadata({ tableName: "${table.name}" }) for fields and relations.`,
|
|
213
|
+
}));
|
|
214
|
+
const matched = q
|
|
215
|
+
? summarized.filter((table) => JSON.stringify(table).toLowerCase().includes(q))
|
|
216
|
+
: summarized;
|
|
217
|
+
const outputLimit = limit || 30;
|
|
218
|
+
return {
|
|
219
|
+
tableCount: tables.length,
|
|
220
|
+
matchedTableCount: matched.length,
|
|
221
|
+
returnedTableCount: Math.min(matched.length, outputLimit),
|
|
222
|
+
search: search || null,
|
|
223
|
+
tables: matched.slice(0, outputLimit),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
202
227
|
function unwrapData(result) {
|
|
203
228
|
return Array.isArray(result?.data) ? result.data : [];
|
|
204
229
|
}
|
|
@@ -250,6 +275,29 @@ function pickCodeSummary(record, fieldName) {
|
|
|
250
275
|
};
|
|
251
276
|
}
|
|
252
277
|
|
|
278
|
+
function summarizeMutationResult(result, action, tableName) {
|
|
279
|
+
const record = firstDataRecord(result);
|
|
280
|
+
return {
|
|
281
|
+
action,
|
|
282
|
+
tableName,
|
|
283
|
+
id: getId(record),
|
|
284
|
+
statusCode: result?.statusCode,
|
|
285
|
+
success: result?.success,
|
|
286
|
+
detailHint: `Use find_one_record or query_table with explicit fields to inspect ${tableName}.`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function getTableSummary(tableName) {
|
|
291
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
|
|
292
|
+
const table = result?.data?.table || result?.data || result?.table || result;
|
|
293
|
+
return summarizeTable(table);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function getPrimaryFieldName(tableName) {
|
|
297
|
+
const table = await getTableSummary(tableName);
|
|
298
|
+
return table?.primaryKey || 'id';
|
|
299
|
+
}
|
|
300
|
+
|
|
253
301
|
async function fetchAll(path) {
|
|
254
302
|
return unwrapData(await fetchAPI(ENFYRA_API_URL, path));
|
|
255
303
|
}
|
|
@@ -290,16 +338,38 @@ const server = new McpServer(
|
|
|
290
338
|
// METADATA TOOLS
|
|
291
339
|
// ============================================================================
|
|
292
340
|
|
|
293
|
-
server.tool('get_all_metadata', 'Get
|
|
341
|
+
server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Use get_table_metadata or inspect_table for detail.', {
|
|
342
|
+
includeFull: z.boolean().optional().default(false).describe('Return full raw metadata. Default false to keep MCP context small.'),
|
|
343
|
+
search: z.string().optional().describe('Optional table-name/alias substring filter.'),
|
|
344
|
+
limit: z.number().optional().describe('Maximum tables returned after search. Default 30.'),
|
|
345
|
+
}, async ({ includeFull, search, limit }) => {
|
|
294
346
|
const result = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
295
|
-
|
|
347
|
+
const payload = includeFull
|
|
348
|
+
? result
|
|
349
|
+
: {
|
|
350
|
+
statusCode: result?.statusCode,
|
|
351
|
+
success: result?.success,
|
|
352
|
+
...summarizeMetadata(result, { search, limit }),
|
|
353
|
+
detailHint: 'Default response is capped and minimal. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for columns, relations, and route context.',
|
|
354
|
+
};
|
|
355
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
296
356
|
});
|
|
297
357
|
|
|
298
|
-
server.tool('get_table_metadata', 'Get metadata for a specific table by name', {
|
|
358
|
+
server.tool('get_table_metadata', 'Get concise metadata for a specific table by name', {
|
|
299
359
|
tableName: z.string().describe('Table name (e.g., "user_definition", "route_definition")'),
|
|
300
|
-
|
|
360
|
+
includeFull: z.boolean().optional().default(false).describe('Return full raw table metadata. Default false to keep MCP context small.'),
|
|
361
|
+
}, async ({ tableName, includeFull }) => {
|
|
301
362
|
const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
|
|
302
|
-
|
|
363
|
+
const table = result?.data?.table || result?.data || result?.table || result;
|
|
364
|
+
const payload = includeFull
|
|
365
|
+
? result
|
|
366
|
+
: {
|
|
367
|
+
statusCode: result?.statusCode,
|
|
368
|
+
success: result?.success,
|
|
369
|
+
table: summarizeTable(table),
|
|
370
|
+
queryHint: `Use query_table({ tableName: "${tableName}", fields: [...] }) for records. query_table without fields returns only the primary key.`,
|
|
371
|
+
};
|
|
372
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
303
373
|
});
|
|
304
374
|
|
|
305
375
|
server.tool(
|
|
@@ -707,27 +777,41 @@ server.tool(
|
|
|
707
777
|
},
|
|
708
778
|
);
|
|
709
779
|
|
|
710
|
-
server.tool('query_table', 'Query any table
|
|
780
|
+
server.tool('query_table', 'Query any route-backed table. Default response is minimal; pass fields explicitly for detail.', {
|
|
711
781
|
tableName: z.string().describe('Table name to query'),
|
|
712
782
|
filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
|
|
713
783
|
sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
|
|
714
784
|
page: z.number().optional().describe('Page number (default: 1)'),
|
|
715
|
-
limit: z.number().optional().describe('Items per page
|
|
716
|
-
fields: z.array(z.string()).optional().describe('Fields to select'),
|
|
785
|
+
limit: z.number().optional().describe('Items per page. Default: 10. Use count_records for counts.'),
|
|
786
|
+
fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
|
|
717
787
|
}, async ({ tableName, filter, sort, page, limit, fields }) => {
|
|
718
788
|
validateTableName(tableName);
|
|
719
789
|
validateFilter(filter);
|
|
720
790
|
|
|
721
791
|
const queryParams = new URLSearchParams();
|
|
792
|
+
const selectedFields = fields && fields.length > 0 ? fields : [await getPrimaryFieldName(tableName)];
|
|
722
793
|
if (filter) queryParams.set('filter', filter);
|
|
723
794
|
if (sort) queryParams.set('sort', sort);
|
|
724
795
|
if (page) queryParams.set('page', String(page));
|
|
725
|
-
|
|
726
|
-
|
|
796
|
+
queryParams.set('limit', String(limit || 10));
|
|
797
|
+
queryParams.set('fields', selectedFields.join(','));
|
|
727
798
|
|
|
728
799
|
const query = queryParams.toString();
|
|
729
800
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}${query ? `?${query}` : ''}`);
|
|
730
|
-
|
|
801
|
+
const payload = {
|
|
802
|
+
statusCode: result?.statusCode,
|
|
803
|
+
success: result?.success,
|
|
804
|
+
tableName,
|
|
805
|
+
fields: selectedFields,
|
|
806
|
+
limit: limit || 10,
|
|
807
|
+
minimalDefaultApplied: !(fields && fields.length > 0),
|
|
808
|
+
meta: result?.meta,
|
|
809
|
+
data: result?.data || [],
|
|
810
|
+
detailHint: fields && fields.length > 0
|
|
811
|
+
? undefined
|
|
812
|
+
: 'Only the primary key was returned because fields was omitted. Re-run query_table with explicit fields for details, or use inspect_table to find valid field names.',
|
|
813
|
+
};
|
|
814
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
731
815
|
});
|
|
732
816
|
|
|
733
817
|
server.tool(
|
|
@@ -780,26 +864,50 @@ server.tool(
|
|
|
780
864
|
tableName: z.string().describe('Table name'),
|
|
781
865
|
id: z.string().optional().describe('Record ID'),
|
|
782
866
|
filter: z.string().optional().describe('Filter as JSON string to find by'),
|
|
867
|
+
fields: z.array(z.string()).optional().describe('Fields to select. If omitted, returns only the primary key.'),
|
|
783
868
|
},
|
|
784
|
-
async ({ tableName, id, filter }) => {
|
|
869
|
+
async ({ tableName, id, filter, fields }) => {
|
|
785
870
|
validateTableName(tableName);
|
|
871
|
+
const primaryKey = await getPrimaryFieldName(tableName);
|
|
872
|
+
const selectedFields = fields && fields.length > 0 ? fields : [primaryKey];
|
|
786
873
|
if (id) {
|
|
787
874
|
// Enfyra route engine does not register GET /<table>/:id (only PATCH/DELETE use /:id). Use list + filter.
|
|
788
|
-
const filterObj = JSON.stringify({
|
|
875
|
+
const filterObj = JSON.stringify({ [primaryKey]: { _eq: id } });
|
|
876
|
+
const queryParams = new URLSearchParams({
|
|
877
|
+
filter: filterObj,
|
|
878
|
+
limit: '1',
|
|
879
|
+
fields: selectedFields.join(','),
|
|
880
|
+
});
|
|
789
881
|
const result = await fetchAPI(
|
|
790
882
|
ENFYRA_API_URL,
|
|
791
|
-
`/${tableName}
|
|
883
|
+
`/${tableName}?${queryParams.toString()}`,
|
|
792
884
|
);
|
|
793
885
|
const one = result.data?.[0] ?? null;
|
|
794
|
-
return { content: [{ type: 'text', text: JSON.stringify(
|
|
886
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
887
|
+
tableName,
|
|
888
|
+
primaryKey,
|
|
889
|
+
fields: selectedFields,
|
|
890
|
+
data: one,
|
|
891
|
+
detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
|
|
892
|
+
}, null, 2) }] };
|
|
795
893
|
}
|
|
796
894
|
if (!filter) throw new Error('Provide id or filter');
|
|
797
895
|
validateFilter(filter);
|
|
896
|
+
const queryParams = new URLSearchParams({
|
|
897
|
+
filter,
|
|
898
|
+
limit: '1',
|
|
899
|
+
fields: selectedFields.join(','),
|
|
900
|
+
});
|
|
798
901
|
const result = await fetchAPI(
|
|
799
902
|
ENFYRA_API_URL,
|
|
800
|
-
`/${tableName}
|
|
903
|
+
`/${tableName}?${queryParams.toString()}`,
|
|
801
904
|
);
|
|
802
|
-
return { content: [{ type: 'text', text: JSON.stringify(
|
|
905
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
906
|
+
tableName,
|
|
907
|
+
fields: selectedFields,
|
|
908
|
+
data: result.data?.[0] || null,
|
|
909
|
+
detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
|
|
910
|
+
}, null, 2) }] };
|
|
803
911
|
},
|
|
804
912
|
);
|
|
805
913
|
|
|
@@ -813,7 +921,7 @@ server.tool('create_record', 'Create a new record in any table', {
|
|
|
813
921
|
}, async ({ tableName, data }) => {
|
|
814
922
|
validateTableName(tableName);
|
|
815
923
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}`, { method: 'POST', body: data });
|
|
816
|
-
return { content: [{ type: 'text', text:
|
|
924
|
+
return { content: [{ type: 'text', text: JSON.stringify(summarizeMutationResult(result, 'created', tableName), null, 2) }] };
|
|
817
925
|
});
|
|
818
926
|
|
|
819
927
|
server.tool('update_record', 'Update an existing record by ID using PATCH', {
|
|
@@ -823,7 +931,7 @@ server.tool('update_record', 'Update an existing record by ID using PATCH', {
|
|
|
823
931
|
}, async ({ tableName, id, data }) => {
|
|
824
932
|
validateTableName(tableName);
|
|
825
933
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'PATCH', body: data });
|
|
826
|
-
return { content: [{ type: 'text', text:
|
|
934
|
+
return { content: [{ type: 'text', text: JSON.stringify(summarizeMutationResult(result, 'updated', tableName), null, 2) }] };
|
|
827
935
|
});
|
|
828
936
|
|
|
829
937
|
server.tool('delete_record', 'Delete a record by ID', {
|
|
@@ -832,7 +940,13 @@ server.tool('delete_record', 'Delete a record by ID', {
|
|
|
832
940
|
}, async ({ tableName, id }) => {
|
|
833
941
|
validateTableName(tableName);
|
|
834
942
|
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'DELETE' });
|
|
835
|
-
return { content: [{ type: 'text', text:
|
|
943
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
944
|
+
action: 'deleted',
|
|
945
|
+
tableName,
|
|
946
|
+
id,
|
|
947
|
+
statusCode: result?.statusCode,
|
|
948
|
+
success: result?.success,
|
|
949
|
+
}, null, 2) }] };
|
|
836
950
|
});
|
|
837
951
|
|
|
838
952
|
server.tool(
|
|
@@ -987,7 +1101,7 @@ function enrichRoute(route, state) {
|
|
|
987
1101
|
.map((item) => pickCodeSummary({
|
|
988
1102
|
...item,
|
|
989
1103
|
method: item.method ? { ...item.method, method: state.methodIdNameMap[String(getId(item.method))] || item.method.method || null } : item.method,
|
|
990
|
-
}, '
|
|
1104
|
+
}, 'sourceCode'));
|
|
991
1105
|
const routePreHooks = withMethodNames(
|
|
992
1106
|
state.preHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
|
|
993
1107
|
state.methodIdNameMap,
|
|
@@ -1138,7 +1252,7 @@ server.tool(
|
|
|
1138
1252
|
relations: table.relations?.map((relation) => ({ propertyName: relation.propertyName, description: relation.description })),
|
|
1139
1253
|
}));
|
|
1140
1254
|
const routeMatches = state.routes.filter((route) => matchesText(route));
|
|
1141
|
-
const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, '
|
|
1255
|
+
const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, 'sourceCode'));
|
|
1142
1256
|
const preHookMatches = state.preHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
|
|
1143
1257
|
const postHookMatches = state.postHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
|
|
1144
1258
|
const guardMatches = state.guards.filter((guard) => matchesText(guard));
|
|
@@ -1234,12 +1348,40 @@ server.tool(
|
|
|
1234
1348
|
},
|
|
1235
1349
|
);
|
|
1236
1350
|
|
|
1237
|
-
server.tool('get_all_routes', 'List
|
|
1351
|
+
server.tool('get_all_routes', 'List route definitions with minimal fields. Call inspect_route for handlers/hooks/permissions detail.', {
|
|
1238
1352
|
includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
|
|
1239
|
-
|
|
1353
|
+
search: z.string().optional().describe('Optional path or table substring filter. Use this before creating a route to check duplicates.'),
|
|
1354
|
+
limit: z.number().optional().describe('Maximum routes returned after search. Default 50 to keep response small.'),
|
|
1355
|
+
}, async ({ includeDisabled, search, limit }) => {
|
|
1240
1356
|
const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1357
|
+
const queryParams = new URLSearchParams({
|
|
1358
|
+
filter: JSON.stringify(filter),
|
|
1359
|
+
fields: 'id,path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled',
|
|
1360
|
+
limit: '1000',
|
|
1361
|
+
});
|
|
1362
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/route_definition?${queryParams.toString()}`);
|
|
1363
|
+
const routeLimit = limit || 50;
|
|
1364
|
+
const q = search ? search.toLowerCase() : null;
|
|
1365
|
+
const allRoutes = summarizeRoutes(result);
|
|
1366
|
+
const matchedRoutes = q
|
|
1367
|
+
? allRoutes.filter((route) => JSON.stringify({
|
|
1368
|
+
path: route.path,
|
|
1369
|
+
mainTable: route.mainTable,
|
|
1370
|
+
}).toLowerCase().includes(q))
|
|
1371
|
+
: allRoutes;
|
|
1372
|
+
const payload = {
|
|
1373
|
+
statusCode: result?.statusCode,
|
|
1374
|
+
success: result?.success,
|
|
1375
|
+
totalRouteCount: allRoutes.length,
|
|
1376
|
+
matchedRouteCount: matchedRoutes.length,
|
|
1377
|
+
returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
|
|
1378
|
+
search: search || null,
|
|
1379
|
+
routes: matchedRoutes.slice(0, routeLimit),
|
|
1380
|
+
detailHint: matchedRoutes.length > routeLimit
|
|
1381
|
+
? `Response truncated to ${routeLimit} routes. Re-run with search or a higher limit, then inspect_route({ path }) for details.`
|
|
1382
|
+
: 'Use inspect_route({ path }) or inspect_route({ routeId }) for handlers, hooks, permissions, and guards.',
|
|
1383
|
+
};
|
|
1384
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1243
1385
|
});
|
|
1244
1386
|
|
|
1245
1387
|
server.tool(
|
|
@@ -1283,7 +1425,19 @@ server.tool(
|
|
|
1283
1425
|
|
|
1284
1426
|
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
1285
1427
|
|
|
1286
|
-
|
|
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
|
-
|
|
1302
|
-
.describe('
|
|
1303
|
-
|
|
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,
|
|
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
|
|
1311
|
-
const methodId = methodMap[
|
|
1312
|
-
if (!methodId) throw new Error(`Unknown method: ${
|
|
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 },
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|