@enfyra/mcp-server 0.0.61 → 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 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/index.mjs`**. This README only covers **MCP installation and configuration** for users/devs.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.61",
3
+ "version": "0.0.62",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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: `GET /enfyra/chat_message_read?fields=id&limit=1&meta=filterCount&filter={
297
- "member": { "id": { "_eq": "<currentUserId>" } },
298
- "isRead": { "_eq": false }
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: `GET /enfyra/order?fields=id,total,customer&deep={
308
- "customer": { "fields": "id,email,displayName" },
309
- "items": {
310
- "fields": "id,quantity,product",
311
- "limit": 20,
312
- "deep": {
313
- "product": { "fields": "id,name,price" }
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.',
@@ -512,11 +524,12 @@ return @DATA\`
512
524
  tableName: "user_definition",
513
525
  columnName: "email",
514
526
  ruleType: "format",
515
- ruleConfig: JSON.stringify({ format: "email" }),
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 = $ctx.$uploadedFile
783
+ code: `const file = @UPLOADED_FILE
771
784
  if (!file) @THROW400("File is required")
772
785
 
773
- return {
774
- filename: file.originalname,
775
- mimetype: file.mimetype,
776
- size: file.size
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
  ],
@@ -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`. `$ctx.$env` currently has no macro; access it directly when needed.',
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.',
@@ -407,11 +411,12 @@ export function buildMcpServerInstructions(apiBaseUrl) {
407
411
  `- \`discover_runtime_context\` → GET metadata/routes/method/runtime-backed tables and infer live primary key/backend context`,
408
412
  `- \`discover_query_capabilities\` → GET metadata/routes and summarize Query DSL/deep/table-specific query contracts`,
409
413
  `- \`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)`,
414
+ `- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args, including filter/sort/page/limit/fields plus optional meta/deep/aggregate)`,
411
415
  `- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
412
416
  `- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
413
417
  `- \`create_record\` → POST \`${base}/<tableName>\` (optional tool queryParams append URL query)`,
414
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>\``,
415
420
  `- \`delete_record\` → DELETE \`${base}/<tableName>/<id>\` after preview + confirm=true (optional tool queryParams append URL query)`,
416
421
  `- \`create_extension\` → POST \`${base}/extension_definition\` (Vue SFC only; for page pass menuId). \`update_record\` on extension_definition to change code.`,
417
422
  `- Flow tables: \`${base}/flow_definition\`, \`${base}/flow_step_definition\`, \`${base}/flow_execution_definition\` — use standard CRUD tools.`,
@@ -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 $helpers; raw create_record on file_definition is not equivalent to multipart upload/storage rollback.',
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
- }, async ({ tableName, filter, sort, page, limit, fields }) => {
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
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
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
- routesReloaded: true,
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
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
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
- routesReloaded: true,
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
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
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
- routesReloaded: true,
1904
+ routeReload,
1829
1905
  }, null, 2) }] };
1830
1906
  },
1831
1907
  );
@@ -1869,7 +1945,7 @@ server.tool(
1869
1945
  }),
1870
1946
  });
1871
1947
 
1872
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
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
- routesReloaded: true,
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
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
2008
- return { content: [{ type: 'text', text: `Route permission created for ${route.path}. Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
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(f => f.name.includes('app-') || f.name.includes('error-'));
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 {