@enfyra/mcp-server 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,6 +65,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
65
65
  '### After a new table is created',
66
66
  '- MCP **`create_table` supports creating columns and relations in the same call**: pass `columns` and `relations` as JSON arrays. Use `create_relation` only when adding a relation to an existing table later.',
67
67
  '- MCP **`create_table` supports `isSingleRecord` directly**. Set `isSingleRecord: true` in the create call for settings/config tables that should keep only one record; do not create first and then patch only for this flag.',
68
+ '- MCP **`create_table` does not accept `alias`**. Do not invent or send alias during table creation; default route/schema behavior is based on `name`. Use `update_table` later only when alias truly needs to change.',
68
69
  '- In `create_table.relations`, each relation uses `targetTable` (table id or `{id}`), `type`, `propertyName`, optional `inversePropertyName` or `mappedBy`, `isNullable`, `onDelete`, and `description`. The target table must already exist.',
69
70
  '- **Never ask for or provide physical FK column names** when creating/updating relations. Do not include `fkCol`, `fkColumn`, `foreignKeyColumn`, `sourceColumn`, `targetColumn`, `junctionSourceColumn`, or `junctionTargetColumn` in create/update payloads unless you are only displaying existing metadata. Enfyra relation cascade derives physical FK/junction names from `propertyName` and table metadata, then hides FK columns from app form/schema definition.',
70
71
  '- For relation CRUD payloads, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`.',
@@ -107,6 +108,14 @@ export function buildMcpServerInstructions(apiBaseUrl) {
107
108
  '- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
108
109
  '- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
109
110
  '',
111
+ '### `$cache` / `@CACHE` user cache',
112
+ '- `$ctx.$cache` and the `@CACHE` macro use Enfyra-managed **user cache**, not the internal runtime metadata cache.',
113
+ '- Script keys are logical keys such as `user:123` or `report:daily`. Do not include `NODE_NAME`, `user_cache:`, Redis prefixes, or another app namespace in script code.',
114
+ '- On Redis-backed deployments, Enfyra stores user cache under the current app `NODE_NAME` namespace as `NODE_NAME:user_cache:*`. Admin Redis Key Editor uses the same storage contract, so values edited there are visible through `$cache` and `@CACHE`.',
115
+ '- User cache is limited by `REDIS_USER_CACHE_LIMIT_MB` (default 30 MB). `REDIS_USER_CACHE_MAX_VALUE_BYTES` optionally rejects oversized single values when greater than 0.',
116
+ '- When user cache exceeds its allocation, Enfyra evicts least-recently-used **user cache** keys only. System Redis keys such as runtime cache snapshots, BullMQ queues, Socket.IO, runtime telemetry, and locks are not counted or evicted by this quota.',
117
+ '- Prefer `set(key, value, ttlMs)` with a TTL. `setNoExpire` is allowed, but persistent user-cache entries can still be evicted by the soft allocation limit.',
118
+ '',
110
119
  '### OAuth login (browser / frontend — not the MCP `login` tool)',
111
120
  '- **MCP `login`** uses **email + password** → `POST {base}/auth/login`. It cannot complete OAuth (no browser redirect).',
112
121
  '- **Supported providers (server):** `google`, `facebook`, `github` only.',
@@ -165,6 +174,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
165
174
  '- Field permission condition DSL is narrower and does not support `_contains`, `_starts_with`, `_ends_with`, or `_between`.',
166
175
  '- Deep shape: `{ relationName: { fields?, filter?, sort?, limit?, page?, deep? } }`. Relation keys are relation `propertyName`, not physical FK columns.',
167
176
  '- Deep validation rejects unknown relation keys, unknown subkeys, `limit` on many-to-one/one-to-one, and invalid dotted sort through many-side relations.',
177
+ '- 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`.',
178
+ '- 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;`.',
168
179
  '',
169
180
  '### GraphQL (same prefix as REST / ENFYRA_API_URL)',
170
181
  `- **POST** \`${graphqlHttpUrl}\` — GraphQL endpoint (body: GraphQL query). With Nuxt base: e.g. \`http://localhost:3000/api/graphql\`. With direct Nest: e.g. \`http://localhost:1105/graphql\`.`,
@@ -301,6 +312,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
301
312
  `- \`discover_query_capabilities\` → GET metadata/routes and summarize Query DSL/deep/table-specific query contracts`,
302
313
  `- \`discover_script_contexts\` → static runtime macro/context map for handlers/hooks/flows/websocket/GraphQL/extensions`,
303
314
  `- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args)`,
315
+ `- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
304
316
  `- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
305
317
  `- \`create_record\` → POST \`${base}/<tableName>\``,
306
318
  `- \`update_record\` → PATCH \`${base}/<tableName>/<id>\``,
@@ -111,20 +111,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
111
111
  'Set `isSingleRecord: true` directly in create_table for settings/config tables that should keep only one record.',
112
112
  `Full URLs: ${apiBase}/<table_name> (example table post: ${apiBase}/post).`,
113
113
  'GraphQL is enabled separately per table through `gql_definition` or `update_table` with `graphqlEnabled`; it is not controlled by route availableMethods.',
114
+ 'Do not set alias during create_table. The create tool accepts name, description, isSingleRecord, columns, and relations only; use update_table later only if alias really needs to change.',
114
115
  ].join(' '),
115
116
  {
116
117
  name: z.string().describe('Table name (e.g., "user_definition", "my_custom_table"). Must be unique, lowercase with underscores.'),
117
- alias: z.string().optional().describe('Table alias for API. If not provided, the table name will be used.'),
118
118
  description: z.string().optional().describe('Description of what this table stores.'),
119
119
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
120
120
  columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, defaultValue?, description?, options? }. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"status","type":"enum","options":["draft","published"]}]'),
121
121
  relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id or {"id": <id>}. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
122
122
  },
123
- async ({ name, alias, description, isSingleRecord, columns: columnsJson, relations: relationsJson }) => {
123
+ async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson }) => {
124
124
  const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
125
125
  const userColumns = parseJsonArrayParam('columns', columnsJson);
126
126
  const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
127
- const body = { name, alias, description, columns: [idColumn, ...userColumns], relations: userRelations };
127
+ const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
128
128
  if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
129
129
  const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
130
130
  method: 'POST',
@@ -349,7 +349,7 @@ server.tool(
349
349
  customRouteWorkflow: 'For a new endpoint use create_route against an existing table, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
350
350
  },
351
351
  schemaManagement: {
352
- createTable: 'POST /table_definition supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly.',
352
+ createTable: 'POST /table_definition supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly. It does not accept alias at create time; table name drives the default route/schema behavior.',
353
353
  updateTable: 'PATCH /table_definition/:id is the canonical path for table property changes and column/relation schema changes.',
354
354
  columns: 'column_definition has no REST route; use create_table/create_column/update_column/delete_column.',
355
355
  relations: routeTables.has('relation_definition')
@@ -448,7 +448,9 @@ server.tool(
448
448
  },
449
449
  cacheAndCluster: {
450
450
  metadataMutationReloads: 'Metadata-backed mutations emit cache invalidation; admin reload endpoints exist for metadata/routes/graphql/guards/all.',
451
- multiInstanceContract: 'Backend is cluster-aware through cache invalidation and Redis/BullMQ paths, but this MCP can only observe metadata/API state, not every node health.',
451
+ runtimeCacheContract: 'REDIS_RUNTIME_CACHE=true stores runtime definition snapshots in Redis so instances with the same NODE_NAME read the same runtime cache namespace.',
452
+ userCacheContract: '$cache/@CACHE uses managed user cache under NODE_NAME:user_cache:* with REDIS_USER_CACHE_LIMIT_MB default 30 MB; quota eviction only removes user cache keys, not runtime cache, BullMQ, Socket.IO, telemetry, or lock keys.',
453
+ multiInstanceContract: 'Backend is cluster-aware through cache invalidation, Redis runtime cache, Redis user cache, and BullMQ paths, but this MCP can only observe metadata/API state, not every node health.',
452
454
  flowWorkerContract: 'Flow jobs require the backend flow worker to be initialized after HTTP listen and websocket gateway init; trigger_flow only confirms enqueue/result from admin endpoint.',
453
455
  },
454
456
  runtimeGaps: [
@@ -498,6 +500,7 @@ server.tool(
498
500
  meta: 'Request metadata/counts where supported.',
499
501
  deep: 'Nested relation fetch object keyed by relation propertyName.',
500
502
  },
503
+ countPattern: 'For counts, query only fields=id with limit=1 and request meta. Use meta=totalCount without a filter, or meta=filterCount when a filter is supplied. MCP count_records wraps this pattern.',
501
504
  deep: {
502
505
  shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
503
506
  rules: [
@@ -552,13 +555,14 @@ server.tool(
552
555
  const payload = {
553
556
  transformer: {
554
557
  rule: 'Dynamic server scripts are transformed before sandbox execution. Macros expand to $ctx paths; comments are not transformed.',
555
- preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use @BODY/@QUERY/@PARAMS/@USER/@REPOS/@HELPERS/@SOCKET/@TRIGGER/@DATA/@ERROR/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
558
+ 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.',
556
559
  coreMacros: {
557
560
  '@BODY': '$ctx.$body',
558
561
  '@QUERY': '$ctx.$query',
559
562
  '@PARAMS': '$ctx.$params',
560
563
  '@USER': '$ctx.$user',
561
564
  '@REPOS': '$ctx.$repos',
565
+ '@CACHE': '$ctx.$cache',
562
566
  '@HELPERS': '$ctx.$helpers',
563
567
  '@SOCKET': '$ctx.$socket',
564
568
  '@DATA': '$ctx.$data',
@@ -576,27 +580,32 @@ server.tool(
576
580
  '@FLOW_META': '$ctx.$flow.$meta',
577
581
  '#table_name': '$ctx.$repos.table_name',
578
582
  },
583
+ cache: {
584
+ contract: '@CACHE and $ctx.$cache use managed user cache. Use logical keys only; Enfyra stores Redis-backed user cache under NODE_NAME:user_cache:* and Redis Admin Key Editor uses the same storage path.',
585
+ quota: 'REDIS_USER_CACHE_LIMIT_MB defaults to 30 MB. If exceeded, Enfyra evicts least-recently-used user-cache keys only; system Redis keys are not counted or evicted.',
586
+ keyRule: 'Do not include NODE_NAME, user_cache:, or Redis namespace prefixes in scripts. Prefer TTL-based set(key, value, ttlMs); setNoExpire may still be evicted by the user-cache soft allocation.',
587
+ },
579
588
  throws: '@THROW400 through @THROW503 and @THROW map to $ctx.$throw helpers.',
580
589
  },
581
590
  contexts: {
582
591
  preHook: {
583
592
  runs: 'Before handler.',
584
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
593
+ data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
585
594
  returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
586
595
  },
587
596
  handler: {
588
597
  runs: 'Main route logic, or canonical CRUD if no handler overrides.',
589
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS.main', '@REPOS.secure', '@HELPERS', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
598
+ data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS.main', '@REPOS.secure', '@CACHE', '@HELPERS', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
590
599
  returnBehavior: 'Return value becomes response body unless post-hook changes it.',
591
600
  },
592
601
  postHook: {
593
602
  runs: 'After handler, including error path.',
594
- data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@USER', '@SHARE', '@API'],
603
+ data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@USER', '@CACHE', '@SHARE', '@API'],
595
604
  returnBehavior: 'Mutate @DATA/$ctx.$data or return a non-undefined replacement response.',
596
605
  },
597
606
  flowStep: {
598
607
  runs: 'Inside flow execution or admin flow step test.',
599
- data: ['@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@HELPERS', '@SOCKET', '@TRIGGER'],
608
+ data: ['@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@SOCKET', '@TRIGGER'],
600
609
  resultBehavior: 'Step return value is injected into @FLOW.<step.key> and @FLOW_LAST.',
601
610
  branching: 'Condition steps use JavaScript truthy/falsy result; child branch is true/false.',
602
611
  },
@@ -626,6 +635,7 @@ server.tool(
626
635
  mutationReturnShape: '$repos.<table>.create({ data }) and $repos.<table>.update({ id, data }) return a collection-shaped result: { data: [...], count? }. data is always an array for create/update, even for one created/updated record. If a script needs the single record object, it must read result.data[0] or result.data?.[0] ?? null.',
627
636
  preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
628
637
  wrongSingleRecordAccess: 'Do not use result.data.id, do not return result.data when one object is expected, and do not assume create/update returns the bare row object.',
638
+ countPattern: 'To count records in custom code, do not fetch full rows. Use const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); then read result.meta.filterCount or result.meta.totalCount.',
629
639
  },
630
640
  socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect because there is no bound socket.',
631
641
  packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
@@ -703,6 +713,49 @@ server.tool('query_table', 'Query any table in Enfyra with filters, sorting, and
703
713
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
704
714
  });
705
715
 
716
+ server.tool(
717
+ 'count_records',
718
+ [
719
+ 'Count records in a route-backed Enfyra table using the lightweight REST meta pattern.',
720
+ 'Without filter it requests fields=id&limit=1&meta=totalCount and returns meta.totalCount.',
721
+ 'With filter it requests fields=id&limit=1&meta=filterCount and returns meta.filterCount.',
722
+ 'Use this instead of fetching rows when the user only needs a count.',
723
+ ].join(' '),
724
+ {
725
+ tableName: z.string().describe('Table name to count. Must have a REST route.'),
726
+ filter: z.string().optional().describe('Optional Query DSL filter as JSON string. Example: \'{"status":{"_eq":"active"}}\''),
727
+ },
728
+ async ({ tableName, filter }) => {
729
+ validateTableName(tableName);
730
+ validateFilter(filter);
731
+
732
+ const metaField = filter ? 'filterCount' : 'totalCount';
733
+ const queryParams = new URLSearchParams();
734
+ queryParams.set('fields', 'id');
735
+ queryParams.set('limit', '1');
736
+ queryParams.set('meta', metaField);
737
+ if (filter) queryParams.set('filter', filter);
738
+
739
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${queryParams.toString()}`);
740
+ const meta = result?.meta || {};
741
+ const hasCount = Object.prototype.hasOwnProperty.call(meta, metaField);
742
+ const count = hasCount ? Number(meta[metaField]) : null;
743
+ const payload = {
744
+ tableName,
745
+ count,
746
+ countField: metaField,
747
+ filterApplied: !!filter,
748
+ meta,
749
+ request: {
750
+ path: `/${tableName}`,
751
+ query: Object.fromEntries(queryParams.entries()),
752
+ },
753
+ warning: hasCount ? undefined : `Response meta did not include ${metaField}.`,
754
+ };
755
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
756
+ },
757
+ );
758
+
706
759
  server.tool(
707
760
  'find_one_record',
708
761
  'Find a single record by ID or filter. By ID uses GET with filter (Enfyra has no GET /table/:id route).',