@enfyra/mcp-server 0.0.61 → 0.0.63
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 +67 -22
- package/src/lib/mcp-instructions.js +8 -2
- package/src/mcp-server-entry.mjs +102 -17
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
|
@@ -249,7 +249,7 @@ create_column({
|
|
|
249
249
|
},
|
|
250
250
|
'queries-deep': {
|
|
251
251
|
title: 'REST queries, filters, meta counts, and deep relation fetches',
|
|
252
|
-
useWhen: 'Use when fetching records, filtering by relations, loading nested data, or counting efficiently.',
|
|
252
|
+
useWhen: 'Use when fetching records, filtering by relations, loading nested relation data in the same request, or counting efficiently.',
|
|
253
253
|
examples: [
|
|
254
254
|
{
|
|
255
255
|
name: 'Minimal MCP query then explicit detail query',
|
|
@@ -293,28 +293,64 @@ 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
|
-
name: '
|
|
307
|
-
code: `
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
"
|
|
311
|
-
"
|
|
312
|
-
"
|
|
313
|
-
|
|
313
|
+
name: 'Relation fields without deep',
|
|
314
|
+
code: `query_table({
|
|
315
|
+
tableName: "order",
|
|
316
|
+
fields: [
|
|
317
|
+
"id",
|
|
318
|
+
"total",
|
|
319
|
+
"customer.id",
|
|
320
|
+
"customer.email",
|
|
321
|
+
"customer.displayName"
|
|
322
|
+
],
|
|
323
|
+
limit: 20
|
|
324
|
+
})`,
|
|
325
|
+
notes: [
|
|
326
|
+
'Use fields with dotted relation paths when you only need scalar fields from related records.',
|
|
327
|
+
'This is enough for simple many-to-one or one-to-one relation display such as owner.email, customer.name, or lastMessage.text.',
|
|
328
|
+
'Do not add deep when fields alone can express the relation data you need.',
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'Deep relation query options',
|
|
333
|
+
code: `query_table({
|
|
334
|
+
tableName: "order",
|
|
335
|
+
fields: ["id", "total"],
|
|
336
|
+
deep: JSON.stringify({
|
|
337
|
+
items: {
|
|
338
|
+
fields: "id,quantity,product",
|
|
339
|
+
sort: "-createdAt",
|
|
340
|
+
limit: 20,
|
|
341
|
+
deep: {
|
|
342
|
+
product: { fields: "id,name,price" }
|
|
343
|
+
}
|
|
314
344
|
}
|
|
315
|
-
}
|
|
316
|
-
}`,
|
|
345
|
+
})
|
|
346
|
+
})`,
|
|
317
347
|
notes: [
|
|
348
|
+
'Use deep when relation loading needs query options such as filter, sort, limit, page, or nested deep.',
|
|
349
|
+
'Deep is mainly useful for controlled child collections or nested relation fetches, not for basic related-field display.',
|
|
350
|
+
'Do not use deep just to filter by a relation id; use a normal relation filter instead.',
|
|
351
|
+
'Do not use deep for counts; use count_records or meta=filterCount/totalCount.',
|
|
352
|
+
'Do not deep-load large child collections without an explicit limit/page. For heavy screens, fetch the parent list first, then load the selected child collection separately with pagination.',
|
|
353
|
+
'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
354
|
'deep keys must be relation property names.',
|
|
319
355
|
'Allowed deep options are fields, filter, sort, limit, page, and deep.',
|
|
320
356
|
'Do not invent deep keys like members unless members is a relation on that table.',
|
|
@@ -512,11 +548,12 @@ return @DATA\`
|
|
|
512
548
|
tableName: "user_definition",
|
|
513
549
|
columnName: "email",
|
|
514
550
|
ruleType: "format",
|
|
515
|
-
|
|
551
|
+
value: JSON.stringify({ v: "email" }),
|
|
516
552
|
message: "Please enter a valid email address"
|
|
517
553
|
})`,
|
|
518
554
|
notes: [
|
|
519
555
|
'Column rules validate canonical POST/PATCH body payloads.',
|
|
556
|
+
'The rule value payload uses the { v: ... } shape; do not pass ruleConfig.',
|
|
520
557
|
'Use column rules before writing custom validation code when the rule is simple.',
|
|
521
558
|
],
|
|
522
559
|
},
|
|
@@ -767,16 +804,24 @@ const uploaded = await fetch("/enfyra/files/upload", {
|
|
|
767
804
|
},
|
|
768
805
|
{
|
|
769
806
|
name: 'Use uploaded file in handler',
|
|
770
|
-
code: `const file =
|
|
807
|
+
code: `const file = @UPLOADED_FILE
|
|
771
808
|
if (!file) @THROW400("File is required")
|
|
772
809
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
810
|
+
const saved = await @STORAGE.$upload({
|
|
811
|
+
file,
|
|
812
|
+
storageConfig: @BODY.storageConfig,
|
|
813
|
+
folder: @BODY.folder,
|
|
814
|
+
title: @BODY.title,
|
|
815
|
+
description: @BODY.description
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
return saved`,
|
|
778
819
|
notes: [
|
|
779
820
|
'Use file-specific context only in upload-capable routes.',
|
|
821
|
+
'For request uploads, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from the temp file path.',
|
|
822
|
+
'Use @STORAGE.$registerFile when an external process already uploaded the object and the script only needs to create the file_definition record.',
|
|
823
|
+
'Do not read @UPLOADED_FILE.path into a Buffer and do not generate examples using @UPLOADED_FILE.buffer.',
|
|
824
|
+
'Use buffer only for small generated or transformed files, such as image thumbnails.',
|
|
780
825
|
],
|
|
781
826
|
},
|
|
782
827
|
],
|
|
@@ -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,6 +110,7 @@ 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.',
|
|
@@ -138,16 +140,18 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
138
140
|
'- You may **mutate** `@DATA` / `$ctx.$data` in place, or **return** a value: a non-`undefined` return replaces `$ctx.$data` as the response body.',
|
|
139
141
|
'',
|
|
140
142
|
'### Dynamic script syntax preference',
|
|
141
|
-
'- 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`.',
|
|
142
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.',
|
|
143
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.',
|
|
144
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.',
|
|
145
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.',
|
|
146
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.',
|
|
147
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.',
|
|
148
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.',
|
|
149
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.',
|
|
150
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.',
|
|
151
155
|
'- Use raw `$ctx` only when there is no template macro for the field or helper you need.',
|
|
152
156
|
'- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
|
|
153
157
|
'- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
|
|
@@ -236,6 +240,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
236
240
|
'- Full filter operators: `_eq`, `_neq`, `_gt`, `_gte`, `_lt`, `_lte`, `_in`, `_not_in`, `_nin`, `_contains`, `_starts_with`, `_ends_with`, `_between`, `_is_null`, `_is_not_null`, `_and`, `_or`, `_not`.',
|
|
237
241
|
'- Field permission condition DSL is narrower and does not support `_contains`, `_starts_with`, `_ends_with`, or `_between`.',
|
|
238
242
|
'- Deep shape: `{ relationName: { fields?, filter?, sort?, limit?, page?, deep? } }`. Relation keys are relation `propertyName`, not physical FK columns.',
|
|
243
|
+
'- Use dotted relation fields such as `owner.email` or `lastMessage.text` when the caller only needs basic related record fields. Use `deep` when relation loading needs query options such as `filter`, `sort`, `limit`, `page`, or nested `deep`. Do not use `deep` for simple relation-id filters, one-row lookup, counts, or large child collections that should be loaded separately with pagination.',
|
|
239
244
|
'- Deep validation rejects unknown relation keys, unknown subkeys, `limit` on many-to-one/one-to-one, and invalid dotted sort through many-side relations.',
|
|
240
245
|
'- To count records over REST, do not fetch full rows. Use MCP **`count_records`**, or call `GET /<table>?fields=id&limit=1&meta=totalCount` without filter and read `meta.totalCount`; with a filter use `meta=filterCount` and read `meta.filterCount`.',
|
|
241
246
|
'- In custom dynamic code, use the same lightweight pattern: `const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); const count = filter ? result.meta?.filterCount : result.meta?.totalCount;`.',
|
|
@@ -407,11 +412,12 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
407
412
|
`- \`discover_runtime_context\` → GET metadata/routes/method/runtime-backed tables and infer live primary key/backend context`,
|
|
408
413
|
`- \`discover_query_capabilities\` → GET metadata/routes and summarize Query DSL/deep/table-specific query contracts`,
|
|
409
414
|
`- \`discover_script_contexts\` → static runtime macro/context map for handlers/hooks/flows/websocket/GraphQL/extensions`,
|
|
410
|
-
`- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args)`,
|
|
415
|
+
`- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args, including filter/sort/page/limit/fields plus optional meta/deep/aggregate)`,
|
|
411
416
|
`- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
|
|
412
417
|
`- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
|
|
413
418
|
`- \`create_record\` → POST \`${base}/<tableName>\` (optional tool queryParams append URL query)`,
|
|
414
419
|
`- \`update_record\` → PATCH \`${base}/<tableName>/<id>\` (optional tool queryParams append URL query)`,
|
|
420
|
+
`- \`update_script_source\` → validates sourceCode with \`${base}/admin/script/validate\`, then PATCHes \`${base}/<script_table>/<id>\``,
|
|
415
421
|
`- \`delete_record\` → DELETE \`${base}/<tableName>/<id>\` after preview + confirm=true (optional tool queryParams append URL query)`,
|
|
416
422
|
`- \`create_extension\` → POST \`${base}/extension_definition\` (Vue SFC only; for page pass menuId). \`update_record\` on extension_definition to change code.`,
|
|
417
423
|
`- Flow tables: \`${base}/flow_definition\`, \`${base}/flow_step_definition\`, \`${base}/flow_execution_definition\` — use standard CRUD tools.`,
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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)) {
|
|
@@ -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'),
|
|
@@ -1695,7 +1771,7 @@ server.tool(
|
|
|
1695
1771
|
body: JSON.stringify(body),
|
|
1696
1772
|
});
|
|
1697
1773
|
|
|
1698
|
-
|
|
1774
|
+
const routeReload = await reloadRoutesResult();
|
|
1699
1775
|
|
|
1700
1776
|
const created = firstDataRecord(result);
|
|
1701
1777
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
@@ -1707,7 +1783,7 @@ server.tool(
|
|
|
1707
1783
|
availableMethods: methods,
|
|
1708
1784
|
publishedMethods: publishedMethods || [],
|
|
1709
1785
|
},
|
|
1710
|
-
|
|
1786
|
+
routeReload,
|
|
1711
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.`,
|
|
1712
1788
|
}, null, 2) }] };
|
|
1713
1789
|
},
|
|
@@ -1764,13 +1840,13 @@ server.tool(
|
|
|
1764
1840
|
});
|
|
1765
1841
|
}
|
|
1766
1842
|
|
|
1767
|
-
|
|
1843
|
+
const routeReload = await reloadRoutesResult();
|
|
1768
1844
|
|
|
1769
1845
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1770
1846
|
action: 'created',
|
|
1771
1847
|
handlers: results,
|
|
1772
1848
|
scriptValidation,
|
|
1773
|
-
|
|
1849
|
+
routeReload,
|
|
1774
1850
|
detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
|
|
1775
1851
|
}, null, 2) }] };
|
|
1776
1852
|
},
|
|
@@ -1815,7 +1891,7 @@ server.tool(
|
|
|
1815
1891
|
}),
|
|
1816
1892
|
});
|
|
1817
1893
|
|
|
1818
|
-
|
|
1894
|
+
const routeReload = await reloadRoutesResult();
|
|
1819
1895
|
|
|
1820
1896
|
const created = firstDataRecord(result);
|
|
1821
1897
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
@@ -1825,7 +1901,7 @@ server.tool(
|
|
|
1825
1901
|
name,
|
|
1826
1902
|
routeId,
|
|
1827
1903
|
scriptValidation,
|
|
1828
|
-
|
|
1904
|
+
routeReload,
|
|
1829
1905
|
}, null, 2) }] };
|
|
1830
1906
|
},
|
|
1831
1907
|
);
|
|
@@ -1869,7 +1945,7 @@ server.tool(
|
|
|
1869
1945
|
}),
|
|
1870
1946
|
});
|
|
1871
1947
|
|
|
1872
|
-
|
|
1948
|
+
const routeReload = await reloadRoutesResult();
|
|
1873
1949
|
|
|
1874
1950
|
const created = firstDataRecord(result);
|
|
1875
1951
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
@@ -1879,7 +1955,7 @@ server.tool(
|
|
|
1879
1955
|
name,
|
|
1880
1956
|
routeId,
|
|
1881
1957
|
scriptValidation,
|
|
1882
|
-
|
|
1958
|
+
routeReload,
|
|
1883
1959
|
}, null, 2) }] };
|
|
1884
1960
|
},
|
|
1885
1961
|
);
|
|
@@ -2004,8 +2080,14 @@ server.tool(
|
|
|
2004
2080
|
method: 'POST',
|
|
2005
2081
|
body: JSON.stringify(body),
|
|
2006
2082
|
});
|
|
2007
|
-
|
|
2008
|
-
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) }] };
|
|
2009
2091
|
},
|
|
2010
2092
|
);
|
|
2011
2093
|
|
|
@@ -2142,7 +2224,10 @@ server.tool('search_logs', 'Search for ERROR or WARN logs across recent log file
|
|
|
2142
2224
|
}, async ({ level, keyword, limit }) => {
|
|
2143
2225
|
const logFilesResult = await fetchAPI(ENFYRA_API_URL, '/logs');
|
|
2144
2226
|
const logFiles = logFilesResult.files || [];
|
|
2145
|
-
const recentFiles = logFiles.filter(
|
|
2227
|
+
const recentFiles = logFiles.filter((file) => {
|
|
2228
|
+
const name = file?.name || '';
|
|
2229
|
+
return /^app[.-]/.test(name) || /^error[.-]/.test(name);
|
|
2230
|
+
});
|
|
2146
2231
|
const results = [];
|
|
2147
2232
|
for (const file of recentFiles.slice(0, 3)) {
|
|
2148
2233
|
try {
|