@enfyra/mcp-server 0.0.60 → 0.0.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +44 -22
- package/src/lib/mcp-instructions.js +12 -5
- package/src/mcp-server-entry.mjs +159 -53
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
MCP server for managing Enfyra instances from **Codex**, **Claude Code**, **Cursor**, and other MCP-compatible clients. All operations go through Enfyra's REST API.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
**LLM rules (REST, GraphQL, auth, URL, mutation `create_{tableName}`, etc.):** not in this README — see **`src/lib/mcp-instructions.js`** (content sent via MCP `instructions`), **`src/lib/mcp-examples.js`** (concrete examples loaded through `get_enfyra_examples`), and tool descriptions in **`src/
|
|
6
|
+
**LLM rules (REST, GraphQL, auth, URL, mutation `create_{tableName}`, etc.):** not in this README — see **`src/lib/mcp-instructions.js`** (content sent via MCP `instructions`), **`src/lib/mcp-examples.js`** (concrete examples loaded through `get_enfyra_examples`), and tool descriptions in **`src/mcp-server-entry.mjs`**. This README only covers **MCP installation and configuration** for users/devs.
|
|
7
7
|
|
|
8
8
|
**Official docs:** [Claude Code MCP](https://docs.anthropic.com/en/docs/claude-code/mcp) · [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings) · [Cursor MCP (`mcp.json`)](https://cursor.com/docs/context/mcp)
|
|
9
9
|
|
|
@@ -186,6 +186,10 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
|
|
|
186
186
|
|
|
187
187
|
Schema and script tools include safety guards for LLM callers: generic record mutations validate request fields against live metadata, script-backed records must validate `sourceCode` before save through `/admin/script/validate` and fail closed if validation is unavailable, relation metadata rejects physical FK/junction inputs, custom routes reject `mainTableId` unless the path is the canonical table route, schema tools serialize table/column/relation changes, and destructive deletes require `confirm=true` after returning a preview.
|
|
188
188
|
|
|
189
|
+
Quick checklist for a new LLM using Enfyra MCP: discover the live system first, inspect the specific table/route, load the matching example category, mutate with explicit fields and relation property names, validate or test scripts/routes before relying on them, re-read the saved row when mutation output is summarized, and preview destructive operations before confirming.
|
|
190
|
+
|
|
191
|
+
Use `update_script_source` when updating existing long script-backed records such as `flow_step_definition`, `route_handler_definition`, hook tables, websocket scripts, GraphQL scripts, or bootstrap scripts. It accepts raw `sourceCode` directly, validates the source, and saves `sourceCode`/`scriptLanguage` without requiring the caller to manually JSON-escape the full script. Use generic `update_record` for small record patches or patches that include non-script metadata fields.
|
|
192
|
+
|
|
189
193
|
For route contracts that intentionally keep workflow fields out of request bodies, generic `create_record`, `update_record`, and `delete_record` accept optional `queryParams` as a JSON object string. For example, a renewal workflow can keep `expires_at=YYYY-MM-DD` in the URL query while `validateBody` remains enabled for the table body.
|
|
190
194
|
|
|
191
195
|
### `ENFYRA_API_URL` — use the app proxy
|
package/package.json
CHANGED
package/src/lib/mcp-examples.js
CHANGED
|
@@ -293,28 +293,40 @@ GET /enfyra/post?filter={"<primaryKeyFromMetadata>":{"_eq":123}}&limit=1`,
|
|
|
293
293
|
},
|
|
294
294
|
{
|
|
295
295
|
name: 'Count without loading all rows',
|
|
296
|
-
code: `
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
code: `query_table({
|
|
297
|
+
tableName: "chat_message_read",
|
|
298
|
+
fields: ["id"],
|
|
299
|
+
limit: 1,
|
|
300
|
+
meta: "filterCount",
|
|
301
|
+
filter: JSON.stringify({
|
|
302
|
+
member: { id: { _eq: "<currentUserId>" } },
|
|
303
|
+
isRead: { _eq: false }
|
|
304
|
+
})
|
|
305
|
+
})`,
|
|
300
306
|
notes: [
|
|
301
307
|
'Use meta=totalCount with no filter and meta=filterCount with a filter.',
|
|
308
|
+
'MCP count_records wraps this pattern for simple counts.',
|
|
302
309
|
'Do not fetch all rows only to count them.',
|
|
303
310
|
],
|
|
304
311
|
},
|
|
305
312
|
{
|
|
306
313
|
name: 'Deep relation query',
|
|
307
|
-
code: `
|
|
308
|
-
|
|
309
|
-
"
|
|
310
|
-
|
|
311
|
-
"
|
|
312
|
-
|
|
313
|
-
|
|
314
|
+
code: `query_table({
|
|
315
|
+
tableName: "order",
|
|
316
|
+
fields: ["id", "total", "customer"],
|
|
317
|
+
deep: JSON.stringify({
|
|
318
|
+
customer: { fields: "id,email,displayName" },
|
|
319
|
+
items: {
|
|
320
|
+
fields: "id,quantity,product",
|
|
321
|
+
limit: 20,
|
|
322
|
+
deep: {
|
|
323
|
+
product: { fields: "id,name,price" }
|
|
324
|
+
}
|
|
314
325
|
}
|
|
315
|
-
}
|
|
316
|
-
}`,
|
|
326
|
+
})
|
|
327
|
+
})`,
|
|
317
328
|
notes: [
|
|
329
|
+
'Use query_table deep for normal MCP reads; use test_rest_endpoint only when you need a custom raw URL or route behavior test.',
|
|
318
330
|
'deep keys must be relation property names.',
|
|
319
331
|
'Allowed deep options are fields, filter, sort, limit, page, and deep.',
|
|
320
332
|
'Do not invent deep keys like members unless members is a relation on that table.',
|
|
@@ -497,11 +509,11 @@ return @DATA\`
|
|
|
497
509
|
tableName: "route_definition",
|
|
498
510
|
id: "<route_id>",
|
|
499
511
|
data: {
|
|
500
|
-
publishedMethods: [{ id:
|
|
512
|
+
publishedMethods: [{ id: "<GET_method_id_from_list_methods>" }]
|
|
501
513
|
}
|
|
502
514
|
})`,
|
|
503
515
|
notes: [
|
|
504
|
-
'Method
|
|
516
|
+
'Method ids are instance data. Use list_methods or inspect_route output to resolve the GET method id first.',
|
|
505
517
|
'publishedMethods controls anonymous route access. Route permissions are not for public access.',
|
|
506
518
|
'Route permissions apply when the method is not public.',
|
|
507
519
|
],
|
|
@@ -512,11 +524,12 @@ return @DATA\`
|
|
|
512
524
|
tableName: "user_definition",
|
|
513
525
|
columnName: "email",
|
|
514
526
|
ruleType: "format",
|
|
515
|
-
|
|
527
|
+
value: JSON.stringify({ v: "email" }),
|
|
516
528
|
message: "Please enter a valid email address"
|
|
517
529
|
})`,
|
|
518
530
|
notes: [
|
|
519
531
|
'Column rules validate canonical POST/PATCH body payloads.',
|
|
532
|
+
'The rule value payload uses the { v: ... } shape; do not pass ruleConfig.',
|
|
520
533
|
'Use column rules before writing custom validation code when the rule is simple.',
|
|
521
534
|
],
|
|
522
535
|
},
|
|
@@ -767,16 +780,24 @@ const uploaded = await fetch("/enfyra/files/upload", {
|
|
|
767
780
|
},
|
|
768
781
|
{
|
|
769
782
|
name: 'Use uploaded file in handler',
|
|
770
|
-
code: `const file =
|
|
783
|
+
code: `const file = @UPLOADED_FILE
|
|
771
784
|
if (!file) @THROW400("File is required")
|
|
772
785
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
786
|
+
const saved = await @STORAGE.$upload({
|
|
787
|
+
file,
|
|
788
|
+
storageConfig: @BODY.storageConfig,
|
|
789
|
+
folder: @BODY.folder,
|
|
790
|
+
title: @BODY.title,
|
|
791
|
+
description: @BODY.description
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
return saved`,
|
|
778
795
|
notes: [
|
|
779
796
|
'Use file-specific context only in upload-capable routes.',
|
|
797
|
+
'For request uploads, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from the temp file path.',
|
|
798
|
+
'Use @STORAGE.$registerFile when an external process already uploaded the object and the script only needs to create the file_definition record.',
|
|
799
|
+
'Do not read @UPLOADED_FILE.path into a Buffer and do not generate examples using @UPLOADED_FILE.buffer.',
|
|
800
|
+
'Use buffer only for small generated or transformed files, such as image thumbnails.',
|
|
780
801
|
],
|
|
781
802
|
},
|
|
782
803
|
],
|
|
@@ -802,6 +823,7 @@ update_method({
|
|
|
802
823
|
})`,
|
|
803
824
|
notes: [
|
|
804
825
|
'Use dedicated method tools instead of generic CRUD on method_definition.',
|
|
826
|
+
'The backend stores the method label in method_definition.name; do not send or filter a method_definition.method field.',
|
|
805
827
|
'buttonColor is the badge background and textColor is the badge text color.',
|
|
806
828
|
'The eApp management UI is /settings/methods.',
|
|
807
829
|
'delete_method is preview-first and should only be used for unused custom methods.',
|
|
@@ -29,6 +29,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
29
29
|
`**Full URL:** base + path segment. Example for table \`post\`: \`${examplePost}\`.`,
|
|
30
30
|
'',
|
|
31
31
|
'### First-step rule: discover before answering architecture/capability questions',
|
|
32
|
+
'- **New LLM checklist:** discover live context first → inspect the specific table/route → load matching examples → mutate with explicit fields and relation property names → validate/test scripts or routes → re-read saved rows when mutation output is summarized → preview destructive actions before confirming.',
|
|
32
33
|
'- If the user asks what Enfyra supports, how to build a feature, which API exists, or whether a tool/schema path can do something, call **`discover_enfyra_system`** first. It reads live metadata, route definitions, method rows, route-backed tables, no-route tables, and capability areas.',
|
|
33
34
|
'- If the question depends on DB type, primary key convention, cache/reload/runtime state, active GraphQL/flow/websocket/storage counts, or admin surfaces, call **`discover_runtime_context`**.',
|
|
34
35
|
'- If the question depends on filters, sorting, deep relations, relation property names, field permissions, or table-specific query examples, call **`discover_query_capabilities`**; pass `tableName` when known.',
|
|
@@ -109,14 +110,16 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
109
110
|
'- For generic MCP `create_record` and `update_record`, the `data` argument is a **JSON string**, not a JavaScript object. Example: `data: "{\\"name\\":\\"Starter\\"}"`. If the host gives a validation error saying `data` expected string, stringify the object before calling the tool.',
|
|
110
111
|
'- Generic MCP `create_record` and `update_record` validate body keys against live metadata before sending REST. If a field such as `expiredAt` is not in metadata, do not bypass body validation; add the metadata field through schema tools or pass route workflow fields in the tool `queryParams` JSON when that route explicitly owns a query contract such as `{"expired_at":"2026-09-20"}`.',
|
|
111
112
|
'- Generic MCP script-table mutations reject `compiledCode`, reject legacy `code` aliases, and must validate `sourceCode` with `/admin/script/validate` before saving. If validation is unavailable or fails, the save must fail closed. Prefer dedicated `create_handler`, `create_pre_hook`, and `create_post_hook` tools for route code.',
|
|
113
|
+
'- For long script updates on existing flow steps, handlers, hooks, websocket scripts, GraphQL scripts, or bootstrap scripts, prefer **`update_script_source`** over generic `update_record`. It accepts raw `sourceCode` as a normal tool argument, validates it, then PATCHes `sourceCode`/`scriptLanguage`; this avoids brittle manual JSON escaping for large scripts.',
|
|
112
114
|
'- Generic MCP `delete_record` is destructive but preview-first: the first call without `confirm=true` returns the target preview; call it again with `confirm=true` only after the user explicitly approves deletion. Use `queryParams` for route-specific confirmation contracts instead of putting those fields in the body.',
|
|
113
115
|
'- Relation fields (publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
|
|
114
116
|
'- **mainTable warning:** do not set `mainTable` on custom routes. It is reserved for canonical table routes only.',
|
|
115
117
|
' - **Many-to-one:** `"someRelation": {"id": 4}` (single object with id)',
|
|
116
118
|
' - **One-to-many / many-to-many:** `"publishedMethods": [{"id": 1}, {"id": 2}]` (array of objects with id)',
|
|
117
|
-
'- **Method IDs**
|
|
119
|
+
'- **Method IDs** are instance data, not a stable contract. Query `method_definition` or use method names through MCP route helpers before setting `publishedMethods`, `availableMethods`, `skipRoleGuardMethods`, hook methods, handler methods, or route permissions. Default CRUD records are `GET`, `POST`, `PATCH`, and `DELETE`; create extra records such as `PUT` through method tools when a route needs them.',
|
|
120
|
+
'- `method_definition.name` is the unique backend field for the HTTP method label. Do not filter, create, or update a `method_definition.method` field. MCP method tools accept an input named `method` for usability, but they write/read `name` on the server.',
|
|
118
121
|
'- **Wrong:** `"publishedMethods": ["GET"]` or `"publishedMethods": [{"method": "GET"}]` — rejected or silently ignored.',
|
|
119
|
-
'- **Right:**
|
|
122
|
+
'- **Right:** first query method records, then pass their ids, for example `"publishedMethods": [{"id": <GET_METHOD_ID>}]`. Multiple methods use multiple id objects.',
|
|
120
123
|
'- **To unset:** pass empty array `"publishedMethods": []`.',
|
|
121
124
|
'',
|
|
122
125
|
'### Dynamic script `$repos` mutation return shape',
|
|
@@ -137,16 +140,18 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
137
140
|
'- You may **mutate** `@DATA` / `$ctx.$data` in place, or **return** a value: a non-`undefined` return replaces `$ctx.$data` as the response body.',
|
|
138
141
|
'',
|
|
139
142
|
'### Dynamic script syntax preference',
|
|
140
|
-
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.
|
|
143
|
+
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@STORAGE`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, `@ENV`, and `@THROW400`–`@THROW503`.',
|
|
141
144
|
'- Use Enfyra native throw helpers for intentional errors: `@THROW400("message")`, `@THROW403()`, `@THROW404("resource", id)`, or `$ctx.$throw[400]("message")`. Do not generate `throw new Error(...)` for user/domain errors in handlers, hooks, flows, websocket events, OAuth scripts, or admin-generated scripts.',
|
|
142
145
|
'- For regular app data that must be encrypted at rest, create the column with `isEncrypted=true`; Enfyra database-query hooks will encrypt on insert/update and decrypt after select. `isEncrypted` does not imply immutability; use `isUpdatable=false` separately only when the field itself must be immutable. Do not filter or sort on encrypted fields. Do not generate new route pre-hooks for manual encryption.',
|
|
143
146
|
'- Enfyra scripts use `$helpers.$crypto` for bounded crypto helpers such as `randomUUID()`, `randomBytes(size, encoding)`, `sha256(value, encoding)`, `hmacSha256(value, secret, encoding)`, and `generateSshKeyPair(comment)`. Do not generate legacy `$helpers.$ssh` or `$helpers.$secrets` usage.',
|
|
144
147
|
'- `$ctx.$env` exposes only a sanitized process env snapshot. Current OSS deny keys are exact matches: `DB_URI`, `DB_REPLICA_URIS`, `REDIS_URI`, `SECRET_KEY`, and `ADMIN_PASSWORD`. Do not read secrets from `$ctx.$env`; model app secrets as unpublished `isEncrypted=true` fields instead.',
|
|
145
148
|
'- 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.',
|
|
149
|
+
'- Use MCP `update_script_source` for existing long script-backed records so callers pass raw source text instead of hand-escaped JSON strings. Use generic `update_record` only when the patch is small or includes non-script metadata fields.',
|
|
146
150
|
'- 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.',
|
|
147
151
|
'- 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.',
|
|
148
152
|
'- 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.',
|
|
149
153
|
'- Do not coerce dynamic script values with `String(...)`, `Number(...)`, or `Boolean(...)`. Enfyra payloads, user ids, record ids, and relation ids should keep their runtime type; validate required values and pass them through directly.',
|
|
154
|
+
'- Multipart request files are exposed as `@UPLOADED_FILE` / `$ctx.$uploadedFile` metadata with a server temp-file path. To save or replace that request file, call `@STORAGE.$upload({ file: @UPLOADED_FILE, ... })` or `@STORAGE.$update(id, { file: @UPLOADED_FILE, ... })` so Enfyra streams from disk. To register an object that already exists in storage without uploading bytes, call `@STORAGE.$registerFile({ filename, mimetype, location, size, storageConfig, ... })`. Do not generate `@UPLOADED_FILE.buffer` examples or read the temp file into a Buffer. Use `buffer` only for small generated/transformed files.',
|
|
150
155
|
'- Use raw `$ctx` only when there is no template macro for the field or helper you need.',
|
|
151
156
|
'- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
|
|
152
157
|
'- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
|
|
@@ -298,6 +303,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
298
303
|
'- **Operational list data loading:** do not use arbitrary fixed limits such as `limit=50` as the whole data strategy for admin pages. Use pagination, expose result count when the API supports `meta=filterCount`, and add search/filter controls for natural lookup keys such as id, name, slug, status, email, or external reference.',
|
|
299
304
|
'- **ESV aggregate contract:** aggregate query must be an object keyed by a real field or relation, for example `aggregate: { id: { count: true }, status: { count: { _eq: "failed" } }, amount: { sum: true } }`. Results are returned in `response.meta.aggregate`. Time windows and cross-field conditions belong in top-level `filter`, not inside a field aggregate condition. Field aggregate conditions only support operators on that same field; relation aggregates use `countRecords`.',
|
|
300
305
|
'- **Aggregate numeric rule:** `sum` and `avg` require a numeric field in ESV. Do not aggregate money stored as varchar/text. Use a numeric money field such as `amount_usd` with type `float`, `amount_cents`, or `amount` for revenue stats, or build a dedicated stats route that normalizes legacy values explicitly. If metadata says `float` but SQL aggregate still fails with `sum(character varying)`, the Enfyra Server physical schema is stale or missing the SQL float DDL mapping and must be redeployed/healed before relying on aggregate.',
|
|
306
|
+
'- **Snapshot migrations:** backend metadata/physical schema renames belong in `data/snapshot-migration.json` via table-driven `columnsToModify` entries. The server migration/self-heal path should read table name plus `oldName`/`newName` dynamically; do not hard-code one-off table repairs when the snapshot migration contract can express the change.',
|
|
301
307
|
'- **Partial reload default:** ESV/ASV automatically triggers partial reloads for metadata, routes, menus, extensions, flows, handlers, and related caches after successful writes. Do not reflexively call `/admin/reload`, `/admin/reload/metadata`, or `/admin/reload/routes` after each change. Verify naturally first; use manual reload only when verification shows stale behavior, a reload event failed, or a concrete error indicates the partial reload did not apply.',
|
|
302
308
|
'- **Menu/extension realtime reload contract:** `menu_definition` and `extension_definition` writes are runtime UI changes, not plain CRUD. The server cache orchestrator must emit `$system:reload` through the admin Socket.IO channel with identifiers that eApp handles; eApp must refetch menus/rebuild the menu registry for menu reloads and invalidate dynamic extension caches for extension reloads. Menu reloads can change route-to-extension mapping, so they should also invalidate extension cache. If an open admin tab does not reflect menu/extension changes, debug this two-sided reload contract before telling the user to refresh.',
|
|
303
309
|
'- **Dashboard stats:** time range buttons must change the query filter and reload stats. Dashboards should summarize actionable errors and high-level activity; successful/no-error background runs usually do not need a standalone page unless there is a real workflow to manage.',
|
|
@@ -306,7 +312,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
306
312
|
'- **Do not misuse PageHeader stats:** `PageHeader.stats` renders prominent stat cards inside the shell header. Do not put normal operational KPIs, capacity totals, 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.',
|
|
307
313
|
'- **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 report", permission: { and: [{ route: "/report_definition", methods: ["POST"] }] }, onClick }`.',
|
|
308
314
|
'- **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.',
|
|
309
|
-
'- **HTTP method management:** use the dedicated MCP tools `list_methods`, `create_method`, `update_method`, and preview-first `delete_method` for `method_definition`. The eApp UI for the same records is `/settings/methods`. Method color fields are `buttonColor` for badge background and `textColor` for badge text, both full hex colors. Do not use generic `create_record` on `method_definition` unless the dedicated tool is unavailable.',
|
|
315
|
+
'- **HTTP method management:** use the dedicated MCP tools `list_methods`, `create_method`, `update_method`, and preview-first `delete_method` for `method_definition`. The backend field is `method_definition.name`, unique per method; do not send `method_definition.method`. The eApp UI for the same records is `/settings/methods`. Method color fields are `buttonColor` for badge background and `textColor` for badge text, both full hex colors. Do not use generic `create_record` on `method_definition` unless the dedicated tool is unavailable.',
|
|
310
316
|
'- **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.',
|
|
311
317
|
'- **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/report_definition")`, because Vue compiles template helpers to `_ctx.*`.',
|
|
312
318
|
'- **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.',
|
|
@@ -405,11 +411,12 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
405
411
|
`- \`discover_runtime_context\` → GET metadata/routes/method/runtime-backed tables and infer live primary key/backend context`,
|
|
406
412
|
`- \`discover_query_capabilities\` → GET metadata/routes and summarize Query DSL/deep/table-specific query contracts`,
|
|
407
413
|
`- \`discover_script_contexts\` → static runtime macro/context map for handlers/hooks/flows/websocket/GraphQL/extensions`,
|
|
408
|
-
`- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args)`,
|
|
414
|
+
`- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args, including filter/sort/page/limit/fields plus optional meta/deep/aggregate)`,
|
|
409
415
|
`- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
|
|
410
416
|
`- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
|
|
411
417
|
`- \`create_record\` → POST \`${base}/<tableName>\` (optional tool queryParams append URL query)`,
|
|
412
418
|
`- \`update_record\` → PATCH \`${base}/<tableName>/<id>\` (optional tool queryParams append URL query)`,
|
|
419
|
+
`- \`update_script_source\` → validates sourceCode with \`${base}/admin/script/validate\`, then PATCHes \`${base}/<script_table>/<id>\``,
|
|
413
420
|
`- \`delete_record\` → DELETE \`${base}/<tableName>/<id>\` after preview + confirm=true (optional tool queryParams append URL query)`,
|
|
414
421
|
`- \`create_extension\` → POST \`${base}/extension_definition\` (Vue SFC only; for page pass menuId). \`update_record\` on extension_definition to change code.`,
|
|
415
422
|
`- Flow tables: \`${base}/flow_definition\`, \`${base}/flow_step_definition\`, \`${base}/flow_execution_definition\` — use standard CRUD tools.`,
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -34,7 +34,7 @@ const CAPABILITY_AREAS = [
|
|
|
34
34
|
{
|
|
35
35
|
area: 'Dynamic REST API',
|
|
36
36
|
tables: ['route_definition', 'route_handler_definition', 'pre_hook_definition', 'post_hook_definition', 'route_permission_definition', 'method_definition'],
|
|
37
|
-
workflow: 'Create custom paths with create_route without mainTableId, then add handlers/hooks. mainTableId is only for canonical table routes like /table_name.
|
|
37
|
+
workflow: 'Create custom paths with create_route without mainTableId, then add handlers/hooks. mainTableId is only for canonical table routes like /table_name. Query method_definition before assigning route methods.',
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
area: 'Auth, roles, sessions, OAuth',
|
|
@@ -197,8 +197,8 @@ function summarizeRoutes(routesResult) {
|
|
|
197
197
|
id: route.id ?? route._id,
|
|
198
198
|
path: route.path,
|
|
199
199
|
mainTable: route.mainTable?.name || route.mainTableName || null,
|
|
200
|
-
availableMethods: (route.availableMethods || []).map((method) => method.
|
|
201
|
-
publishedMethods: (route.publishedMethods || []).map((method) => method.
|
|
200
|
+
availableMethods: (route.availableMethods || []).map((method) => method.name).filter(Boolean),
|
|
201
|
+
publishedMethods: (route.publishedMethods || []).map((method) => method.name).filter(Boolean),
|
|
202
202
|
isEnabled: route.isEnabled,
|
|
203
203
|
}));
|
|
204
204
|
}
|
|
@@ -258,6 +258,23 @@ function parseJsonArg(value, fallback = undefined) {
|
|
|
258
258
|
return JSON.parse(value);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
async function reloadRoutesResult() {
|
|
262
|
+
try {
|
|
263
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
|
|
264
|
+
return {
|
|
265
|
+
attempted: true,
|
|
266
|
+
succeeded: true,
|
|
267
|
+
result,
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return {
|
|
271
|
+
attempted: true,
|
|
272
|
+
succeeded: false,
|
|
273
|
+
error: error?.message || String(error),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
261
278
|
function normalizeRestPath(path) {
|
|
262
279
|
if (!path) return '/';
|
|
263
280
|
if (/^https?:\/\//i.test(path)) {
|
|
@@ -376,8 +393,8 @@ function normalizeHexColorInput(value, fieldName) {
|
|
|
376
393
|
}
|
|
377
394
|
|
|
378
395
|
async function findMethodRecordByName(method) {
|
|
379
|
-
const filter = encodeURIComponent(JSON.stringify({
|
|
380
|
-
const result = await fetchAPI(ENFYRA_API_URL, `/method_definition?filter=${filter}&limit=1&fields=id,_id,
|
|
396
|
+
const filter = encodeURIComponent(JSON.stringify({ name: { _eq: method } }));
|
|
397
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/method_definition?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
|
|
381
398
|
return unwrapData(result)[0] || null;
|
|
382
399
|
}
|
|
383
400
|
|
|
@@ -476,7 +493,7 @@ server.tool(
|
|
|
476
493
|
routes: routes.length,
|
|
477
494
|
methods: methodsResult?.data?.length || 0,
|
|
478
495
|
},
|
|
479
|
-
methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id,
|
|
496
|
+
methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name, method: method.name })),
|
|
480
497
|
capabilityAreas: CAPABILITY_AREAS.map((item) => ({
|
|
481
498
|
...item,
|
|
482
499
|
presentTables: item.tables.filter((table) => tableNames.includes(table)),
|
|
@@ -579,7 +596,7 @@ server.tool(
|
|
|
579
596
|
storageConfigs: storageResult?.data?.length || 0,
|
|
580
597
|
settings: settingsResult?.data?.length || 0,
|
|
581
598
|
},
|
|
582
|
-
methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id,
|
|
599
|
+
methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name, method: method.name })),
|
|
583
600
|
routeRuntime: {
|
|
584
601
|
routePattern: 'GET/POST /<route-path>; PATCH/DELETE /<route-path>/:id; no dynamic GET /<route-path>/:id.',
|
|
585
602
|
adminRoutes: adminRoutes.map((route) => route.path).sort(),
|
|
@@ -698,15 +715,17 @@ server.tool(
|
|
|
698
715
|
const payload = {
|
|
699
716
|
transformer: {
|
|
700
717
|
rule: 'Dynamic server scripts are transformed before sandbox execution. Macros expand to $ctx paths; comments are not transformed.',
|
|
701
|
-
preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use @BODY/@QUERY/@PARAMS/@USER/@REPOS/@CACHE/@HELPERS/@SOCKET/@TRIGGER/@DATA/@ERROR/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
|
|
718
|
+
preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use @BODY/@QUERY/@PARAMS/@USER/@REPOS/@CACHE/@HELPERS/@STORAGE/@SOCKET/@TRIGGER/@DATA/@ERROR/@ENV/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
|
|
702
719
|
coreMacros: {
|
|
703
720
|
'@BODY': '$ctx.$body',
|
|
704
721
|
'@QUERY': '$ctx.$query',
|
|
705
722
|
'@PARAMS': '$ctx.$params',
|
|
706
723
|
'@USER': '$ctx.$user',
|
|
724
|
+
'@ENV': '$ctx.$env',
|
|
707
725
|
'@REPOS': '$ctx.$repos',
|
|
708
726
|
'@CACHE': '$ctx.$cache',
|
|
709
727
|
'@HELPERS': '$ctx.$helpers',
|
|
728
|
+
'@STORAGE': '$ctx.$storage',
|
|
710
729
|
'@SOCKET': '$ctx.$socket',
|
|
711
730
|
'@DATA': '$ctx.$data',
|
|
712
731
|
'@STATUS': '$ctx.$statusCode',
|
|
@@ -731,20 +750,21 @@ server.tool(
|
|
|
731
750
|
throws: '@THROW400 through @THROW503 and @THROW map to $ctx.$throw helpers.',
|
|
732
751
|
helpers: {
|
|
733
752
|
crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Use generateSshKeyPair for SSH key material. Do not use legacy $ctx.$helpers.$ssh.',
|
|
753
|
+
files: '$ctx.$storage.$upload and $ctx.$storage.$update accept file: @UPLOADED_FILE for request uploads and stream from the server temp file path. $ctx.$storage.$registerFile creates a file_definition record for an object that already exists in storage without uploading bytes. Use buffer only for small generated/transformed files; do not use @UPLOADED_FILE.buffer.',
|
|
734
754
|
},
|
|
735
755
|
env: '$ctx.$env exposes a sanitized process env snapshot with exact sensitive keys removed: DB_URI, DB_REPLICA_URIS, REDIS_URI, SECRET_KEY, and ADMIN_PASSWORD. Store app secrets in unpublished isEncrypted fields instead of reading them from $env.',
|
|
736
756
|
},
|
|
737
757
|
contexts: {
|
|
738
758
|
preHook: {
|
|
739
759
|
runs: 'Before handler.',
|
|
740
|
-
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
|
|
760
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@STORAGE', '@THROW*', '@SOCKET emit helpers'],
|
|
741
761
|
queryContract: '@QUERY.filter is initialized as an object. When adding RLS/scope filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
|
|
742
762
|
rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
|
|
743
763
|
returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
|
|
744
764
|
},
|
|
745
765
|
handler: {
|
|
746
766
|
runs: 'Main route logic, or canonical CRUD if no handler overrides.',
|
|
747
|
-
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS.main', '@REPOS.secure', '@CACHE', '@HELPERS', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
|
|
767
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main', '@REPOS.secure', '@CACHE', '@HELPERS', '@STORAGE', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
|
|
748
768
|
returnBehavior: 'Return value becomes response body unless post-hook changes it.',
|
|
749
769
|
},
|
|
750
770
|
postHook: {
|
|
@@ -754,7 +774,7 @@ server.tool(
|
|
|
754
774
|
},
|
|
755
775
|
flowStep: {
|
|
756
776
|
runs: 'Inside flow execution or admin flow step test.',
|
|
757
|
-
data: ['@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@SOCKET', '@TRIGGER'],
|
|
777
|
+
data: ['@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@STORAGE', '@SOCKET', '@TRIGGER'],
|
|
758
778
|
resultBehavior: 'Step return value is injected into @FLOW.<step.key> and @FLOW_LAST.',
|
|
759
779
|
branching: 'Condition steps use JavaScript truthy/falsy result; child branch is true/false.',
|
|
760
780
|
},
|
|
@@ -788,7 +808,7 @@ server.tool(
|
|
|
788
808
|
},
|
|
789
809
|
socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect because there is no bound socket.',
|
|
790
810
|
packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
|
|
791
|
-
files: 'Upload helpers are on $
|
|
811
|
+
files: 'Upload helpers are on $storage; raw create_record on file_definition is not equivalent to multipart upload/storage rollback. For multipart request files, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from disk-backed temp storage. Use @STORAGE.$registerFile only when the object already exists in storage and the script should create the file_definition record without uploading bytes. Use buffer only for small generated files.',
|
|
792
812
|
},
|
|
793
813
|
adminTesting: {
|
|
794
814
|
flowStep: 'Use test_flow_step or run_admin_test(kind=flow_step).',
|
|
@@ -846,15 +866,23 @@ server.tool('query_table', 'Query any route-backed table. Default response is mi
|
|
|
846
866
|
page: z.number().optional().describe('Page number (default: 1)'),
|
|
847
867
|
limit: z.number().optional().describe('Items per page. Default: 10. Use count_records for counts.'),
|
|
848
868
|
fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
|
|
849
|
-
|
|
869
|
+
meta: z.string().optional().describe('Optional REST meta request, e.g. "totalCount", "filterCount", or aggregate modes supported by the route. Use count_records for simple counts.'),
|
|
870
|
+
deep: z.string().optional().describe('Optional deep relation fetch object as JSON string. Keys must be relation propertyName values.'),
|
|
871
|
+
aggregate: z.string().optional().describe('Optional aggregate object as JSON string, keyed by real fields/relations. Results are returned in response.meta.aggregate when supported.'),
|
|
872
|
+
}, async ({ tableName, filter, sort, page, limit, fields, meta, deep, aggregate }) => {
|
|
850
873
|
validateTableName(tableName);
|
|
851
874
|
validateFilter(filter);
|
|
875
|
+
parseJsonArg(deep, undefined);
|
|
876
|
+
parseJsonArg(aggregate, undefined);
|
|
852
877
|
|
|
853
878
|
const queryParams = new URLSearchParams();
|
|
854
879
|
const selectedFields = fields && fields.length > 0 ? fields : [await getPrimaryFieldName(tableName)];
|
|
855
880
|
if (filter) queryParams.set('filter', filter);
|
|
856
881
|
if (sort) queryParams.set('sort', sort);
|
|
857
882
|
if (page) queryParams.set('page', String(page));
|
|
883
|
+
if (meta) queryParams.set('meta', meta);
|
|
884
|
+
if (deep) queryParams.set('deep', deep);
|
|
885
|
+
if (aggregate) queryParams.set('aggregate', aggregate);
|
|
858
886
|
queryParams.set('limit', String(limit || 10));
|
|
859
887
|
queryParams.set('fields', selectedFields.join(','));
|
|
860
888
|
|
|
@@ -866,6 +894,11 @@ server.tool('query_table', 'Query any route-backed table. Default response is mi
|
|
|
866
894
|
tableName,
|
|
867
895
|
fields: selectedFields,
|
|
868
896
|
limit: limit || 10,
|
|
897
|
+
queryOptions: {
|
|
898
|
+
meta: meta || null,
|
|
899
|
+
deep: deep ? parseJsonArg(deep, null) : null,
|
|
900
|
+
aggregate: aggregate ? parseJsonArg(aggregate, null) : null,
|
|
901
|
+
},
|
|
869
902
|
minimalDefaultApplied: !(fields && fields.length > 0),
|
|
870
903
|
meta: result?.meta,
|
|
871
904
|
data: result?.data || [],
|
|
@@ -1008,6 +1041,49 @@ server.tool('update_record', 'Update an existing record by ID using PATCH. The t
|
|
|
1008
1041
|
}, null, 2) }] };
|
|
1009
1042
|
});
|
|
1010
1043
|
|
|
1044
|
+
server.tool(
|
|
1045
|
+
'update_script_source',
|
|
1046
|
+
[
|
|
1047
|
+
'Update sourceCode on a script-backed record without forcing the caller to JSON-escape long code.',
|
|
1048
|
+
'Use this for flow_step_definition, route_handler_definition, pre_hook_definition, post_hook_definition, websocket_event_definition, websocket_definition, gql_definition, and bootstrap_script_definition.',
|
|
1049
|
+
'The tool validates sourceCode through /admin/script/validate before saving and never accepts compiledCode.',
|
|
1050
|
+
].join(' '),
|
|
1051
|
+
{
|
|
1052
|
+
tableName: z.enum([
|
|
1053
|
+
'route_handler_definition',
|
|
1054
|
+
'pre_hook_definition',
|
|
1055
|
+
'post_hook_definition',
|
|
1056
|
+
'flow_step_definition',
|
|
1057
|
+
'websocket_event_definition',
|
|
1058
|
+
'websocket_definition',
|
|
1059
|
+
'gql_definition',
|
|
1060
|
+
'bootstrap_script_definition',
|
|
1061
|
+
]).describe('Script-backed table to update'),
|
|
1062
|
+
id: z.string().describe('Record ID to update'),
|
|
1063
|
+
sourceCode: z.string().describe('Editable script sourceCode. Pass the raw code string; do not JSON-escape it yourself.'),
|
|
1064
|
+
scriptLanguage: z.string().optional().default('javascript').describe('Script language, usually javascript or typescript'),
|
|
1065
|
+
},
|
|
1066
|
+
async ({ tableName, id, sourceCode, scriptLanguage }) => {
|
|
1067
|
+
validateTableName(tableName);
|
|
1068
|
+
const prepared = await prepareGenericMutation(
|
|
1069
|
+
tableName,
|
|
1070
|
+
JSON.stringify({ sourceCode, scriptLanguage }),
|
|
1071
|
+
);
|
|
1072
|
+
const result = await fetchAPI(
|
|
1073
|
+
ENFYRA_API_URL,
|
|
1074
|
+
`/${tableName}/${encodeURIComponent(String(id))}`,
|
|
1075
|
+
{ method: 'PATCH', body: JSON.stringify(prepared.payload) },
|
|
1076
|
+
);
|
|
1077
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1078
|
+
...summarizeMutationResult(result, 'updated_script_source', tableName),
|
|
1079
|
+
id,
|
|
1080
|
+
sourceLength: sourceCode.length,
|
|
1081
|
+
scriptLanguage,
|
|
1082
|
+
scriptValidation: prepared.scriptValidation,
|
|
1083
|
+
}, null, 2) }] };
|
|
1084
|
+
},
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1011
1087
|
server.tool('delete_record', 'Delete a record by ID', {
|
|
1012
1088
|
tableName: z.string().describe('Table name'),
|
|
1013
1089
|
id: z.string().describe('Record ID to delete'),
|
|
@@ -1050,10 +1126,11 @@ server.tool(
|
|
|
1050
1126
|
'List method_definition records with their UI colors. Use this before creating route methods or method-colored UI.',
|
|
1051
1127
|
{},
|
|
1052
1128
|
async () => {
|
|
1053
|
-
const result = await fetchAPI(ENFYRA_API_URL, '/method_definition?fields=id,_id,
|
|
1129
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/method_definition?fields=id,_id,name,buttonColor,textColor,isSystem&sort=name&limit=0');
|
|
1054
1130
|
const methods = unwrapData(result).map((method) => ({
|
|
1055
1131
|
id: getId(method),
|
|
1056
|
-
|
|
1132
|
+
name: method.name,
|
|
1133
|
+
method: method.name,
|
|
1057
1134
|
buttonColor: method.buttonColor,
|
|
1058
1135
|
textColor: method.textColor,
|
|
1059
1136
|
isSystem: method.isSystem === true,
|
|
@@ -1082,7 +1159,7 @@ server.tool(
|
|
|
1082
1159
|
throw new Error(`Method ${normalizedMethod} already exists with id ${getId(existing)}. Use update_method to change colors.`);
|
|
1083
1160
|
}
|
|
1084
1161
|
const body = {
|
|
1085
|
-
|
|
1162
|
+
name: normalizedMethod,
|
|
1086
1163
|
buttonColor: normalizeHexColorInput(buttonColor, 'buttonColor'),
|
|
1087
1164
|
textColor: normalizeHexColorInput(textColor, 'textColor'),
|
|
1088
1165
|
isSystem: isSystem === true,
|
|
@@ -1094,6 +1171,7 @@ server.tool(
|
|
|
1094
1171
|
_methodMap = null;
|
|
1095
1172
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1096
1173
|
...summarizeMutationResult(result, 'created', 'method_definition'),
|
|
1174
|
+
name: normalizedMethod,
|
|
1097
1175
|
method: normalizedMethod,
|
|
1098
1176
|
appUi: '/settings/methods',
|
|
1099
1177
|
}, null, 2) }] };
|
|
@@ -1128,7 +1206,7 @@ server.tool(
|
|
|
1128
1206
|
body.textColor = normalizeHexColorInput(textColor, 'textColor');
|
|
1129
1207
|
}
|
|
1130
1208
|
if (method !== undefined && id) {
|
|
1131
|
-
body.
|
|
1209
|
+
body.name = normalizeMethodNameInput(method);
|
|
1132
1210
|
}
|
|
1133
1211
|
if (Object.keys(body).length === 0) {
|
|
1134
1212
|
throw new Error('Provide buttonColor, textColor, or a new method name.');
|
|
@@ -1168,13 +1246,14 @@ server.tool(
|
|
|
1168
1246
|
if (!target) {
|
|
1169
1247
|
const primaryKey = await getPrimaryFieldName('method_definition');
|
|
1170
1248
|
const filter = encodeURIComponent(JSON.stringify({ [primaryKey]: { _eq: targetId } }));
|
|
1171
|
-
const result = await fetchAPI(ENFYRA_API_URL, `/method_definition?filter=${filter}&limit=1&fields=id,_id,
|
|
1249
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/method_definition?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
|
|
1172
1250
|
target = unwrapData(result)[0] || null;
|
|
1173
1251
|
}
|
|
1174
1252
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1175
1253
|
action: 'delete_method_preview',
|
|
1176
1254
|
id: targetId,
|
|
1177
|
-
|
|
1255
|
+
name: target?.name,
|
|
1256
|
+
method: target?.name,
|
|
1178
1257
|
isSystem: target?.isSystem === true,
|
|
1179
1258
|
destructive: true,
|
|
1180
1259
|
warning: 'Only delete unused custom methods. Deleting a method can affect route method relations.',
|
|
@@ -1265,7 +1344,7 @@ async function getMethodMap() {
|
|
|
1265
1344
|
const result = await fetchAPI(ENFYRA_API_URL, '/method_definition?limit=0');
|
|
1266
1345
|
_methodMap = {};
|
|
1267
1346
|
for (const m of result.data) {
|
|
1268
|
-
_methodMap[m.
|
|
1347
|
+
_methodMap[m.name] = m.id || m._id;
|
|
1269
1348
|
}
|
|
1270
1349
|
return _methodMap;
|
|
1271
1350
|
}
|
|
@@ -1289,7 +1368,8 @@ function withMethodNames(records, methodIdNameMap, field = 'methods') {
|
|
|
1289
1368
|
[field]: Array.isArray(record?.[field])
|
|
1290
1369
|
? record[field].map((item) => ({
|
|
1291
1370
|
...item,
|
|
1292
|
-
|
|
1371
|
+
name: item.name || methodIdNameMap[String(getId(item))] || null,
|
|
1372
|
+
method: item.name || methodIdNameMap[String(getId(item))] || null,
|
|
1293
1373
|
}))
|
|
1294
1374
|
: record?.[field],
|
|
1295
1375
|
}));
|
|
@@ -1344,7 +1424,11 @@ function enrichRoute(route, state) {
|
|
|
1344
1424
|
.filter((item) => sameId(refId(item.route), routeId))
|
|
1345
1425
|
.map((item) => pickCodeSummary({
|
|
1346
1426
|
...item,
|
|
1347
|
-
method: item.method ? {
|
|
1427
|
+
method: item.method ? {
|
|
1428
|
+
...item.method,
|
|
1429
|
+
name: state.methodIdNameMap[String(getId(item.method))] || item.method.name || null,
|
|
1430
|
+
method: state.methodIdNameMap[String(getId(item.method))] || item.method.name || null,
|
|
1431
|
+
} : item.method,
|
|
1348
1432
|
}, 'sourceCode'));
|
|
1349
1433
|
const routePreHooks = withMethodNames(
|
|
1350
1434
|
state.preHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
|
|
@@ -1369,13 +1453,25 @@ function enrichRoute(route, state) {
|
|
|
1369
1453
|
return {
|
|
1370
1454
|
...route,
|
|
1371
1455
|
availableMethods: Array.isArray(route.availableMethods)
|
|
1372
|
-
? route.availableMethods.map((method) => ({
|
|
1456
|
+
? route.availableMethods.map((method) => ({
|
|
1457
|
+
...method,
|
|
1458
|
+
name: method.name || state.methodIdNameMap[String(getId(method))] || null,
|
|
1459
|
+
method: method.name || state.methodIdNameMap[String(getId(method))] || null,
|
|
1460
|
+
}))
|
|
1373
1461
|
: route.availableMethods,
|
|
1374
1462
|
publishedMethods: Array.isArray(route.publishedMethods)
|
|
1375
|
-
? route.publishedMethods.map((method) => ({
|
|
1463
|
+
? route.publishedMethods.map((method) => ({
|
|
1464
|
+
...method,
|
|
1465
|
+
name: method.name || state.methodIdNameMap[String(getId(method))] || null,
|
|
1466
|
+
method: method.name || state.methodIdNameMap[String(getId(method))] || null,
|
|
1467
|
+
}))
|
|
1376
1468
|
: route.publishedMethods,
|
|
1377
1469
|
skipRoleGuardMethods: Array.isArray(route.skipRoleGuardMethods)
|
|
1378
|
-
? route.skipRoleGuardMethods.map((method) => ({
|
|
1470
|
+
? route.skipRoleGuardMethods.map((method) => ({
|
|
1471
|
+
...method,
|
|
1472
|
+
name: method.name || state.methodIdNameMap[String(getId(method))] || null,
|
|
1473
|
+
method: method.name || state.methodIdNameMap[String(getId(method))] || null,
|
|
1474
|
+
}))
|
|
1379
1475
|
: route.skipRoleGuardMethods,
|
|
1380
1476
|
handlers: routeHandlers,
|
|
1381
1477
|
preHooks: routePreHooks,
|
|
@@ -1536,7 +1632,7 @@ server.tool(
|
|
|
1536
1632
|
'Use this after inspecting a route or changing handlers/hooks/guards. Pass paths like /table_definition?limit=1, not external URLs.',
|
|
1537
1633
|
].join(' '),
|
|
1538
1634
|
{
|
|
1539
|
-
method: z.
|
|
1635
|
+
method: z.string().optional().default('GET').describe('HTTP method name. Must exist in method_definition.name for Enfyra route-backed calls.'),
|
|
1540
1636
|
path: z.string().describe('Enfyra API path, e.g. /route_definition?limit=1'),
|
|
1541
1637
|
query: z.string().optional().describe('Optional query params JSON object, merged onto path query string'),
|
|
1542
1638
|
body: z.string().optional().describe('Optional JSON request body string'),
|
|
@@ -1544,6 +1640,7 @@ server.tool(
|
|
|
1544
1640
|
useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test published/public access.'),
|
|
1545
1641
|
},
|
|
1546
1642
|
async ({ method, path, query, body, headers, useAuth }) => {
|
|
1643
|
+
const httpMethod = normalizeMethodNameInput(method || 'GET');
|
|
1547
1644
|
const restPath = normalizeRestPath(path);
|
|
1548
1645
|
const url = new URL(`${ENFYRA_API_URL.replace(/\/$/, '')}${restPath}`);
|
|
1549
1646
|
const queryObj = parseJsonArg(query, {});
|
|
@@ -1561,9 +1658,9 @@ server.tool(
|
|
|
1561
1658
|
|
|
1562
1659
|
const started = Date.now();
|
|
1563
1660
|
const response = await fetch(url, {
|
|
1564
|
-
method,
|
|
1661
|
+
method: httpMethod,
|
|
1565
1662
|
headers: requestHeaders,
|
|
1566
|
-
...(body !== undefined && body !== null &&
|
|
1663
|
+
...(body !== undefined && body !== null && httpMethod !== 'GET' ? { body } : {}),
|
|
1567
1664
|
});
|
|
1568
1665
|
const contentType = response.headers.get('content-type') || '';
|
|
1569
1666
|
const responseText = await response.text();
|
|
@@ -1574,7 +1671,7 @@ server.tool(
|
|
|
1574
1671
|
|
|
1575
1672
|
const payload = {
|
|
1576
1673
|
request: {
|
|
1577
|
-
method,
|
|
1674
|
+
method: httpMethod,
|
|
1578
1675
|
url: url.toString(),
|
|
1579
1676
|
authenticated: !!useAuth,
|
|
1580
1677
|
},
|
|
@@ -1641,9 +1738,9 @@ server.tool(
|
|
|
1641
1738
|
{
|
|
1642
1739
|
path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
|
|
1643
1740
|
mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
|
|
1644
|
-
methods: z.array(z.
|
|
1645
|
-
.describe('HTTP
|
|
1646
|
-
publishedMethods: z.array(z.
|
|
1741
|
+
methods: z.array(z.string())
|
|
1742
|
+
.describe('HTTP method names this route supports (availableMethods). Each value must exist in method_definition.name. Common: ["GET","POST","PATCH","DELETE"].'),
|
|
1743
|
+
publishedMethods: z.array(z.string()).optional()
|
|
1647
1744
|
.describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
|
|
1648
1745
|
isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
|
|
1649
1746
|
description: z.string().optional().describe('Route description'),
|
|
@@ -1674,7 +1771,7 @@ server.tool(
|
|
|
1674
1771
|
body: JSON.stringify(body),
|
|
1675
1772
|
});
|
|
1676
1773
|
|
|
1677
|
-
|
|
1774
|
+
const routeReload = await reloadRoutesResult();
|
|
1678
1775
|
|
|
1679
1776
|
const created = firstDataRecord(result);
|
|
1680
1777
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
@@ -1686,8 +1783,8 @@ server.tool(
|
|
|
1686
1783
|
availableMethods: methods,
|
|
1687
1784
|
publishedMethods: publishedMethods || [],
|
|
1688
1785
|
},
|
|
1689
|
-
|
|
1690
|
-
next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET"
|
|
1786
|
+
routeReload,
|
|
1787
|
+
next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET", sourceCode }) for custom code. Create extra method_definition.name rows first for custom methods such as PUT.`,
|
|
1691
1788
|
}, null, 2) }] };
|
|
1692
1789
|
},
|
|
1693
1790
|
);
|
|
@@ -1704,9 +1801,9 @@ server.tool(
|
|
|
1704
1801
|
].join(' '),
|
|
1705
1802
|
{
|
|
1706
1803
|
routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
|
|
1707
|
-
method: z.
|
|
1708
|
-
.describe('Single
|
|
1709
|
-
methods: z.array(z.
|
|
1804
|
+
method: z.string().optional()
|
|
1805
|
+
.describe('Single method_definition.name to create. Prefer this for one handler.'),
|
|
1806
|
+
methods: z.array(z.string()).optional()
|
|
1710
1807
|
.describe('Batch create multiple handlers. Use only when the same sourceCode applies to every method.'),
|
|
1711
1808
|
sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic.'),
|
|
1712
1809
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
|
|
@@ -1743,13 +1840,13 @@ server.tool(
|
|
|
1743
1840
|
});
|
|
1744
1841
|
}
|
|
1745
1842
|
|
|
1746
|
-
|
|
1843
|
+
const routeReload = await reloadRoutesResult();
|
|
1747
1844
|
|
|
1748
1845
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1749
1846
|
action: 'created',
|
|
1750
1847
|
handlers: results,
|
|
1751
1848
|
scriptValidation,
|
|
1752
|
-
|
|
1849
|
+
routeReload,
|
|
1753
1850
|
detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
|
|
1754
1851
|
}, null, 2) }] };
|
|
1755
1852
|
},
|
|
@@ -1768,8 +1865,8 @@ server.tool(
|
|
|
1768
1865
|
name: z.string().describe('Hook name (unique per route)'),
|
|
1769
1866
|
code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
|
|
1770
1867
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
|
|
1771
|
-
methods: z.array(z.
|
|
1772
|
-
.describe('
|
|
1868
|
+
methods: z.array(z.string()).optional()
|
|
1869
|
+
.describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
|
|
1773
1870
|
priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
|
|
1774
1871
|
isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
|
|
1775
1872
|
},
|
|
@@ -1794,7 +1891,7 @@ server.tool(
|
|
|
1794
1891
|
}),
|
|
1795
1892
|
});
|
|
1796
1893
|
|
|
1797
|
-
|
|
1894
|
+
const routeReload = await reloadRoutesResult();
|
|
1798
1895
|
|
|
1799
1896
|
const created = firstDataRecord(result);
|
|
1800
1897
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
@@ -1804,7 +1901,7 @@ server.tool(
|
|
|
1804
1901
|
name,
|
|
1805
1902
|
routeId,
|
|
1806
1903
|
scriptValidation,
|
|
1807
|
-
|
|
1904
|
+
routeReload,
|
|
1808
1905
|
}, null, 2) }] };
|
|
1809
1906
|
},
|
|
1810
1907
|
);
|
|
@@ -1822,8 +1919,8 @@ server.tool(
|
|
|
1822
1919
|
name: z.string().describe('Hook name (unique per route)'),
|
|
1823
1920
|
code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
|
|
1824
1921
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
|
|
1825
|
-
methods: z.array(z.
|
|
1826
|
-
.describe('
|
|
1922
|
+
methods: z.array(z.string()).optional()
|
|
1923
|
+
.describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
|
|
1827
1924
|
priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
|
|
1828
1925
|
isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
|
|
1829
1926
|
},
|
|
@@ -1848,7 +1945,7 @@ server.tool(
|
|
|
1848
1945
|
}),
|
|
1849
1946
|
});
|
|
1850
1947
|
|
|
1851
|
-
|
|
1948
|
+
const routeReload = await reloadRoutesResult();
|
|
1852
1949
|
|
|
1853
1950
|
const created = firstDataRecord(result);
|
|
1854
1951
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
@@ -1858,7 +1955,7 @@ server.tool(
|
|
|
1858
1955
|
name,
|
|
1859
1956
|
routeId,
|
|
1860
1957
|
scriptValidation,
|
|
1861
|
-
|
|
1958
|
+
routeReload,
|
|
1862
1959
|
}, null, 2) }] };
|
|
1863
1960
|
},
|
|
1864
1961
|
);
|
|
@@ -1954,7 +2051,7 @@ server.tool(
|
|
|
1954
2051
|
{
|
|
1955
2052
|
path: z.string().optional().describe('Route path, e.g. /user_definition'),
|
|
1956
2053
|
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
1957
|
-
methods: z.array(z.
|
|
2054
|
+
methods: z.array(z.string()).describe('REST method names this permission allows. Each value must exist in method_definition.name.'),
|
|
1958
2055
|
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
|
|
1959
2056
|
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
|
|
1960
2057
|
description: z.string().optional().describe('Admin note'),
|
|
@@ -1983,8 +2080,14 @@ server.tool(
|
|
|
1983
2080
|
method: 'POST',
|
|
1984
2081
|
body: JSON.stringify(body),
|
|
1985
2082
|
});
|
|
1986
|
-
|
|
1987
|
-
return { content: [{ type: 'text', text:
|
|
2083
|
+
const routeReload = await reloadRoutesResult();
|
|
2084
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
2085
|
+
action: 'created',
|
|
2086
|
+
kind: 'route_permission',
|
|
2087
|
+
route: route.path,
|
|
2088
|
+
routeReload,
|
|
2089
|
+
result,
|
|
2090
|
+
}, null, 2) }] };
|
|
1988
2091
|
},
|
|
1989
2092
|
);
|
|
1990
2093
|
|
|
@@ -1999,7 +2102,7 @@ server.tool(
|
|
|
1999
2102
|
position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard'),
|
|
2000
2103
|
routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id'),
|
|
2001
2104
|
path: z.string().optional().describe('Optional route path'),
|
|
2002
|
-
methods: z.array(z.
|
|
2105
|
+
methods: z.array(z.string()).optional().describe('Method names this guard applies to. Empty means all configured behavior for route/global.'),
|
|
2003
2106
|
combinator: z.enum(['and', 'or']).default('and').describe('How child guards/rules combine'),
|
|
2004
2107
|
priority: z.number().optional().default(0).describe('Lower runs first'),
|
|
2005
2108
|
isGlobal: z.boolean().optional().default(false).describe('Apply globally instead of one route'),
|
|
@@ -2121,7 +2224,10 @@ server.tool('search_logs', 'Search for ERROR or WARN logs across recent log file
|
|
|
2121
2224
|
}, async ({ level, keyword, limit }) => {
|
|
2122
2225
|
const logFilesResult = await fetchAPI(ENFYRA_API_URL, '/logs');
|
|
2123
2226
|
const logFiles = logFilesResult.files || [];
|
|
2124
|
-
const recentFiles = logFiles.filter(
|
|
2227
|
+
const recentFiles = logFiles.filter((file) => {
|
|
2228
|
+
const name = file?.name || '';
|
|
2229
|
+
return /^app[.-]/.test(name) || /^error[.-]/.test(name);
|
|
2230
|
+
});
|
|
2125
2231
|
const results = [];
|
|
2126
2232
|
for (const file of recentFiles.slice(0, 3)) {
|
|
2127
2233
|
try {
|