@enfyra/mcp-server 0.0.22 → 0.0.24
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
|
@@ -64,6 +64,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
64
64
|
'',
|
|
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
|
+
'- 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.',
|
|
67
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.',
|
|
68
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.',
|
|
69
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`.',
|
|
@@ -164,6 +166,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
164
166
|
'- Field permission condition DSL is narrower and does not support `_contains`, `_starts_with`, `_ends_with`, or `_between`.',
|
|
165
167
|
'- Deep shape: `{ relationName: { fields?, filter?, sort?, limit?, page?, deep? } }`. Relation keys are relation `propertyName`, not physical FK columns.',
|
|
166
168
|
'- Deep validation rejects unknown relation keys, unknown subkeys, `limit` on many-to-one/one-to-one, and invalid dotted sort through many-side relations.',
|
|
169
|
+
'- 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`.',
|
|
170
|
+
'- 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;`.',
|
|
167
171
|
'',
|
|
168
172
|
'### GraphQL (same prefix as REST / ENFYRA_API_URL)',
|
|
169
173
|
`- **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\`.`,
|
|
@@ -300,6 +304,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
300
304
|
`- \`discover_query_capabilities\` → GET metadata/routes and summarize Query DSL/deep/table-specific query contracts`,
|
|
301
305
|
`- \`discover_script_contexts\` → static runtime macro/context map for handlers/hooks/flows/websocket/GraphQL/extensions`,
|
|
302
306
|
`- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args)`,
|
|
307
|
+
`- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
|
|
303
308
|
`- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
|
|
304
309
|
`- \`create_record\` → POST \`${base}/<tableName>\``,
|
|
305
310
|
`- \`update_record\` → PATCH \`${base}/<tableName>/<id>\``,
|
package/src/lib/table-tools.js
CHANGED
|
@@ -108,23 +108,27 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
108
108
|
'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
|
|
109
109
|
'REST surface for that route (matches server route engine): 4 HTTP operations — GET `/<table>` (list/filter), POST `/<table>` (create), PATCH `/<table>/:id` (update), DELETE `/<table>/:id` (delete).',
|
|
110
110
|
'There is NO `GET /<table>/:id`. To fetch one row by id, use GET `/<table>?filter={"id":{"_eq":"<id>"}}&limit=1` or tool query_table / find_one_record.',
|
|
111
|
+
'Set `isSingleRecord: true` directly in create_table for settings/config tables that should keep only one record.',
|
|
111
112
|
`Full URLs: ${apiBase}/<table_name> (example table post: ${apiBase}/post).`,
|
|
112
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.',
|
|
113
115
|
].join(' '),
|
|
114
116
|
{
|
|
115
117
|
name: z.string().describe('Table name (e.g., "user_definition", "my_custom_table"). Must be unique, lowercase with underscores.'),
|
|
116
|
-
alias: z.string().optional().describe('Table alias for API. If not provided, the table name will be used.'),
|
|
117
118
|
description: z.string().optional().describe('Description of what this table stores.'),
|
|
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.'),
|
|
118
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"]}]'),
|
|
119
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"}]'),
|
|
120
122
|
},
|
|
121
|
-
async ({ name,
|
|
123
|
+
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson }) => {
|
|
122
124
|
const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
|
|
123
125
|
const userColumns = parseJsonArrayParam('columns', columnsJson);
|
|
124
126
|
const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
|
|
127
|
+
const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
|
|
128
|
+
if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
|
|
125
129
|
const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
|
|
126
130
|
method: 'POST',
|
|
127
|
-
body: JSON.stringify(
|
|
131
|
+
body: JSON.stringify(body),
|
|
128
132
|
});
|
|
129
133
|
const createdTable = Array.isArray(result?.data) ? result.data[0] : result;
|
|
130
134
|
const createdTableId = createdTable?.id ?? createdTable?._id;
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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 columns and relations arrays in the same cascade call. MCP create_table exposes
|
|
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')
|
|
@@ -498,6 +498,7 @@ server.tool(
|
|
|
498
498
|
meta: 'Request metadata/counts where supported.',
|
|
499
499
|
deep: 'Nested relation fetch object keyed by relation propertyName.',
|
|
500
500
|
},
|
|
501
|
+
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
502
|
deep: {
|
|
502
503
|
shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
|
|
503
504
|
rules: [
|
|
@@ -626,6 +627,7 @@ server.tool(
|
|
|
626
627
|
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
628
|
preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
|
|
628
629
|
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.',
|
|
630
|
+
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
631
|
},
|
|
630
632
|
socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect because there is no bound socket.',
|
|
631
633
|
packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
|
|
@@ -703,6 +705,49 @@ server.tool('query_table', 'Query any table in Enfyra with filters, sorting, and
|
|
|
703
705
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
704
706
|
});
|
|
705
707
|
|
|
708
|
+
server.tool(
|
|
709
|
+
'count_records',
|
|
710
|
+
[
|
|
711
|
+
'Count records in a route-backed Enfyra table using the lightweight REST meta pattern.',
|
|
712
|
+
'Without filter it requests fields=id&limit=1&meta=totalCount and returns meta.totalCount.',
|
|
713
|
+
'With filter it requests fields=id&limit=1&meta=filterCount and returns meta.filterCount.',
|
|
714
|
+
'Use this instead of fetching rows when the user only needs a count.',
|
|
715
|
+
].join(' '),
|
|
716
|
+
{
|
|
717
|
+
tableName: z.string().describe('Table name to count. Must have a REST route.'),
|
|
718
|
+
filter: z.string().optional().describe('Optional Query DSL filter as JSON string. Example: \'{"status":{"_eq":"active"}}\''),
|
|
719
|
+
},
|
|
720
|
+
async ({ tableName, filter }) => {
|
|
721
|
+
validateTableName(tableName);
|
|
722
|
+
validateFilter(filter);
|
|
723
|
+
|
|
724
|
+
const metaField = filter ? 'filterCount' : 'totalCount';
|
|
725
|
+
const queryParams = new URLSearchParams();
|
|
726
|
+
queryParams.set('fields', 'id');
|
|
727
|
+
queryParams.set('limit', '1');
|
|
728
|
+
queryParams.set('meta', metaField);
|
|
729
|
+
if (filter) queryParams.set('filter', filter);
|
|
730
|
+
|
|
731
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${queryParams.toString()}`);
|
|
732
|
+
const meta = result?.meta || {};
|
|
733
|
+
const hasCount = Object.prototype.hasOwnProperty.call(meta, metaField);
|
|
734
|
+
const count = hasCount ? Number(meta[metaField]) : null;
|
|
735
|
+
const payload = {
|
|
736
|
+
tableName,
|
|
737
|
+
count,
|
|
738
|
+
countField: metaField,
|
|
739
|
+
filterApplied: !!filter,
|
|
740
|
+
meta,
|
|
741
|
+
request: {
|
|
742
|
+
path: `/${tableName}`,
|
|
743
|
+
query: Object.fromEntries(queryParams.entries()),
|
|
744
|
+
},
|
|
745
|
+
warning: hasCount ? undefined : `Response meta did not include ${metaField}.`,
|
|
746
|
+
};
|
|
747
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
|
|
706
751
|
server.tool(
|
|
707
752
|
'find_one_record',
|
|
708
753
|
'Find a single record by ID or filter. By ID uses GET with filter (Enfyra has no GET /table/:id route).',
|