@enfyra/mcp-server 0.0.15 → 0.0.17

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.15",
3
+ "version": "0.0.17",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,10 @@ export function buildMcpServerInstructions(apiBaseUrl) {
54
54
  '- Otherwise the client must send an **Authorization** header with **Bearer** JWT from login. Then the user must satisfy **routePermissions** (unless root admin).',
55
55
  '- MCP tools that use `fetchAPI` authenticate with the configured admin credentials; explain to users that **direct HTTP** calls need a token unless the route/method is published.',
56
56
  '',
57
+ '### Post-hooks (REST)',
58
+ '- **post-hooks always run** after the handler, including when the handler or a pre-hook throws — then `@ERROR` / `$ctx.$error` is set and `@DATA` is null.',
59
+ '- You may **mutate** `@DATA` / `$ctx.$data` in place, or **return** a value: a non-`undefined` return replaces `$ctx.$data` as the response body.',
60
+ '',
57
61
  '### OAuth login (browser / frontend — not the MCP `login` tool)',
58
62
  '- **MCP `login`** uses **email + password** → `POST {base}/auth/login`. It cannot complete OAuth (no browser redirect).',
59
63
  '- **Supported providers (server):** `google`, `facebook`, `github` only.',
@@ -98,7 +102,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
98
102
  `- **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\`.`,
99
103
  `- **GET** \`${graphqlSchemaUrl}\` — current schema SDL (text); same base pattern as above.`,
100
104
  '- A table appears in the schema if some **enabled route** for that table has **both** `GQL_QUERY` and `GQL_MUTATION` in `availableMethods` and a **`mainTable`** pointing at the table. The route **`path` does not need to be** `/<table_name>` (custom paths still qualify).',
101
- '- **Query** field = same string as `table_definition.name`. **Mutations** are literal concat: `create_`+tableName, `update_`+tableName, `delete_`+tableName (e.g. tableName `post` → `create_post`, input type `postInput`). See `generate-type-defs.ts`. No mutations if no non-PK columns for input.',
105
+ '- **Query** field = same string as `table_definition.name`. **Mutations** are literal concat: `create_`+tableName, `update_`+tableName, `delete_`+tableName (e.g. tableName `post` → `create_post`, input type `postInput`). See `generate-type-defs.ts`. If every column is skipped for input (only PK, or only `createdAt`/`updatedAt`, or all unpublished), the schema emits **no** `Query.<tableName>` and **no** create/update/delete mutations for that table (an output `type` may still exist for relation wiring).',
102
106
  '- **Auth:** `publishedMethods` may include `GQL_QUERY` and/or `GQL_MUTATION` **separately** — each controls anonymous access for queries vs mutations. Otherwise Bearer JWT + `routePermissions` must list the same method key (`GQL_QUERY` / `GQL_MUTATION`).',
103
107
  '- MCP does not wrap GraphQL; use REST tools or tell users the URLs above.',
104
108
  '',
@@ -130,7 +134,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
130
134
  '- **triggerConfig examples**: schedule: `{"cron":"0 2 * * *","timezone":"UTC"}`, manual: `{}`. For event/webhook use cases, create a handler/hook with `@DISPATCH.trigger("flow-name", payload)` instead.',
131
135
  '- **Step config examples**: script: `{"code":"return #user_definition.find({limit:10})"}`, condition: `{"code":"return @LAST?.data?.length > 0"}` (uses JS truthy/falsy: `return user` = truthy if exists, `return null` = falsy), query: `{"table":"user_definition","filter":{"status":{"_eq":"active"}},"limit":10}`, http: `{"url":"https://api.example.com","method":"POST","body":{}}` (auto Content-Type: application/json; **http `url` must be public-safe**—see Safety), sleep: `{"ms":5000}`, trigger_flow: `{"flowId":2}`.',
132
136
  '- **Data chain**: Steps access previous results via `@FLOW.<stepKey>` and `@LAST`. Input payload via `@PAYLOAD`. Repos via `#table_name`.',
133
- '- **Template syntax (flows)**: `@PAYLOAD` → `$ctx.$flow.$payload` (input data), `@LAST` → `$ctx.$flow.$last`, `@FLOW` → `$ctx.$flow`, `@META` → `$ctx.$flow.$meta`, `#table_name` → `$ctx.$repos.table_name`, `@HELPERS` → `$ctx.$helpers`, `@THROW4xx/5xx` → error helpers. Trigger other flows in handlers via `@DISPATCH.trigger(name, payload)` or `$ctx.$dispatch.trigger(name, payload)`.',
137
+ '- **Template syntax (flows)**: `@PAYLOAD` → `$ctx.$flow.$payload` (input data), `@LAST` → `$ctx.$flow.$last`, `@FLOW` → `$ctx.$flow`, `@META` → `$ctx.$flow.$meta`, `#table_name` → `$ctx.$repos.table_name`, `@HELPERS` → `$ctx.$helpers`, `@THROW400`–`@THROW503` / `@THROW` → `$ctx.$throw[...]`. Trigger other flows in handlers via `@DISPATCH.trigger(name, payload)` or `$ctx.$dispatch.trigger(name, payload)`.',
134
138
  '- **Condition branching**: Condition step uses JavaScript truthy/falsy evaluation (e.g. `return user` → truthy if exists, falsy if null/0/undefined). Children with matching `parent: {id: conditionStepId}` and `branch: "true"/"false"` execute. Root steps (no parent) always execute sequentially.',
135
139
  '- **Safety**: Max nesting depth 10 (flow triggering flow). Circular flow detection prevents A→B→A loops. HTTP steps: **SSRF hardening** — only `http`/`https`; blocks `localhost`, private IPs, and hostnames resolving to private IPs (use internet-facing URLs like `https://api.example.com`, not internal services, unless server policy changes). Default HTTP timeout 30s (AbortController). `$dispatch.trigger()` available inside flow steps.',
136
140
  '- **Workflow**: Create flow → `create_record` on `flow_definition`. Add steps → `create_record` on `flow_step_definition` with `flow: {id}`. For branch steps, set `parent: {id: conditionStepId}` and `branch: "true"` or `"false"`. Trigger manually via `POST /admin/flow/trigger/{flowId}`.',
@@ -166,6 +170,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
166
170
  '- **useConfirm:** Confirmation dialogs. Returns `{ confirm({ title, content, confirmText, cancelText }), isVisible, options, onConfirm, onCancel }`.',
167
171
  '- **useHeaderActionRegistry:** Register header actions. Pass array: `useHeaderActionRegistry([{ id, label, onClick, color, icon, order, side, global }])`. Action has `{ id, label, onClick, color, icon, order, side: \'left\'|\'right\', global, component }`.',
168
172
  '- **useSubHeaderActionRegistry:** Same as header but for sub-header.',
173
+ '- **usePageHeaderRegistry:** Page title strip. `{ registerPageHeader, clearPageHeader, pageHeader, hasPageHeader }`. Config: `title`, optional `description`, `stats`, `variant`, `gradient` (`purple`|`blue`|`cyan`|`none` — horizontal strip + leading icon tint), `leadingIcon` (icon name), `hideLeadingIcon`. Call `registerPageHeader` again when title/stats must update (plain object snapshot, not refs inside the config).',
169
174
  '- **useMenuRegistry:** Menu management. Returns `{ menuItems, menuGroups, registerMenuItem, unregisterMenuItem, getMenuItemsBySidebar, findParentMenuIdByPath }`.',
170
175
  '- **useMenuApi:** Low-level menu API.',
171
176
  '- **useFilterQuery:** Filter builder. Returns `{ buildQuery(filter), buildFilterObject(filter), createEmptyFilter(), hasActiveFilters(filter), getFilterSummary(filter, fields), encodeFilterToUrl(filter), parseFilterFromUrl(searchParams) }`.',
@@ -177,7 +182,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
177
182
  '#### Injected UI Components (auto-resolved):',
178
183
  '- **Common:** `EmptyState`, `LoadingState`, `ErrorState`, `PageHeader`, `FormCard`, `Modal`, `Drawer`, `BreadCrumbs`, `ListItem`, `LazyImage`, `GlobalConfirm`, `UploadModal`, `UploadModalLazy`, `AvatarInitials`, `BrandingHeader`, `SettingsCard`, `RouteLoading`',
179
184
  '- **Data Table:** `DataTable`, `DataTableLazy`, `ColumnSelector`',
180
- '- **Form:** `FormEditor`, `FilterEditor`, `FilterHistory`, `FieldSelector`',
185
+ '- **Form:** `FormEditor`, `FormEditorLazy` (same API, lazy-loaded), `FilterEditor`, `FilterHistory`, `FieldSelector`',
181
186
  '- **File Manager:** `FileManager`, `FileView`, `FileGridCard`, `CreateFolderModal`',
182
187
  '- **Menu:** `MenuRenderer`, `MenuItemEditor`',
183
188
  '- **UI:** `UButton`, `UCard`, `UInput`, `UTable`, `UBadge` (if available)',
@@ -190,7 +195,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
190
195
  '- `$ctx` — runtime context',
191
196
  '',
192
197
  '#### Extension types:',
193
- '- **FormEditor field-map:** Customize fields via `:field-map` prop. Options: `label` (override label), `description` (override description), `hideLabel`/`hideDescription` (boolean), `component` (custom Vue component replacing field), `componentProps`, `type` (override field type), `disabled`, `placeholder`. Custom component receives `modelValue`/`update:modelValue`.',
198
+ '- **FormEditor field-map:** Customize fields via `:field-map`. Options: `label`, `description`, `hideLabel`, `hideDescription`, `component`, `componentProps`, `type`, `disabled`, `placeholder`, `permission`, `excludedOptions`/`includedOptions`, `fieldProps` (e.g. grid `class: \'md:col-span-2\'` when `layout=\'grid\'`), `booleanWrapperClass`, `fieldWrapperClass`. Optional `:sections` array of `{ id, title?, hideHeading?, headingClass?, class?, rootClass?, fields: string[] }`; field order follows `fields`; unlisted columns render after. Custom input component: `modelValue` / `update:modelValue`.',
194
199
  '- **type "page":** Full-page extension. Requires `menu: { id }` — create menu first (`create_menu` or `create_record` on `menu_definition`), find by path/label, then create extension with `menu: { id: menuId }`. `menu_definition` uses **label** not name — filter by `label` or `path`.',
195
200
  '- **type "widget":** Widget extension. No menu required. Embed via `<Widget :id="extensionId" />` in other extensions or pages.',
196
201
  '',
@@ -13,6 +13,26 @@ async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
13
13
  return result?.data?.[0] || result?.[0] || null;
14
14
  }
15
15
 
16
+ /**
17
+ * PATCH table_definition with auto-confirm for schema changes.
18
+ * First PATCH returns preview + requiredConfirmHash; this helper
19
+ * automatically resends with ?schemaConfirmHash= to apply.
20
+ */
21
+ async function patchTableAutoConfirm(ENFYRA_API_URL, tableId, body) {
22
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
23
+ method: 'PATCH',
24
+ body: JSON.stringify(body),
25
+ });
26
+ const preview = Array.isArray(result?.data) ? result.data[0] : result?.data;
27
+ if (preview?._preview && preview?.requiredConfirmHash) {
28
+ return fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}?schemaConfirmHash=${preview.requiredConfirmHash}`, {
29
+ method: 'PATCH',
30
+ body: JSON.stringify(body),
31
+ });
32
+ }
33
+ return result;
34
+ }
35
+
16
36
  /**
17
37
  * Register table tools with MCP server
18
38
  */
@@ -40,7 +60,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
40
60
  [
41
61
  'Create a new table definition with an auto-included `id` primary key column.',
42
62
  '**Not** for adding a custom API path or handler only — for that use **`create_route`** with an existing `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
43
- 'Use create_column to add more columns after creation (columns are managed via cascade PATCH on table_definition, NOT via /column_definition).',
63
+ 'PREFERRED: pass `columns` param as JSON array to create table WITH columns in one call (cascade). Only use create_column separately if adding to an existing table later.',
44
64
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
45
65
  'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
46
66
  '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).',
@@ -53,12 +73,14 @@ export function registerTableTools(server, ENFYRA_API_URL) {
53
73
  alias: z.string().optional().describe('Table alias for API. If not provided, the table name will be used.'),
54
74
  description: z.string().optional().describe('Description of what this table stores.'),
55
75
  isEnabled: z.boolean().optional().default(true).describe('Enable table. Set to false to disable.'),
76
+ 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"]}]'),
56
77
  },
57
- async ({ name, alias, description, isEnabled }) => {
78
+ async ({ name, alias, description, isEnabled, columns: columnsJson }) => {
58
79
  const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
80
+ const userColumns = columnsJson ? JSON.parse(columnsJson) : [];
59
81
  const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
60
82
  method: 'POST',
61
- body: JSON.stringify({ name, alias, description, isEnabled, columns: [idColumn] }),
83
+ body: JSON.stringify({ name, alias, description, isEnabled, columns: [idColumn, ...userColumns] }),
62
84
  });
63
85
  const base = ENFYRA_API_URL.replace(/\/$/, '');
64
86
  const routePath = `/${name}`;
@@ -66,8 +88,11 @@ export function registerTableTools(server, ENFYRA_API_URL) {
66
88
  `Auto route path: ${routePath} → full base for REST: ${base}${routePath}`,
67
89
  `REST: GET+POST on ${routePath}; PATCH+DELETE on ${routePath}/:id only. No GET ${routePath}/:id.`,
68
90
  ].join('\n');
91
+ const colHint = userColumns.length
92
+ ? `Table created with ${userColumns.length} column(s) + auto id.`
93
+ : `Table created. Use create_column to add columns (tableId: ${result.id}).`;
69
94
  return {
70
- content: [{ type: 'text', text: `Table created successfully with ID: ${result.id}. Next step: use create_column to add columns (tableId: ${result.id}).\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
95
+ content: [{ type: 'text', text: `${colHint}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
71
96
  };
72
97
  }
73
98
  );
@@ -153,17 +178,14 @@ export function registerTableTools(server, ENFYRA_API_URL) {
153
178
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
154
179
  }
155
180
 
156
- const existingColumns = (tableData.columns || []).map(col => ({ id: col.id }));
181
+ const existingColumns = (tableData.columns || []).map(({ table, ...col }) => col);
157
182
  const newCol = { name, type, isNullable: isNullable ?? true };
158
183
  if (isUnique) newCol.isUnique = true;
159
184
  if (defaultValue !== undefined) newCol.defaultValue = defaultValue;
160
185
  if (description) newCol.description = description;
161
186
  if (options) newCol.options = JSON.parse(options);
162
187
 
163
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
164
- method: 'PATCH',
165
- body: JSON.stringify({ columns: [...existingColumns, newCol] }),
166
- });
188
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns: [...existingColumns, newCol] });
167
189
 
168
190
  return {
169
191
  content: [{ type: 'text', text: `Column "${name}" added to table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -186,36 +208,32 @@ export function registerTableTools(server, ENFYRA_API_URL) {
186
208
  name: z.string().optional().describe('New column name.'),
187
209
  type: z.string().optional().describe('New column type.'),
188
210
  isNullable: z.boolean().optional().describe('Set nullable.'),
189
- isHidden: z.boolean().optional().describe('Hide column from API responses.'),
211
+ isPublished: z.boolean().optional().describe('Set column visibility baseline. false = unpublished (omitted from response unless allowed by field permission rules).'),
190
212
  defaultValue: z.string().optional().describe('New default value as JSON string.'),
191
213
  description: z.string().optional().describe('New description.'),
192
214
  options: z.string().optional().describe('New options as JSON string.'),
193
215
  },
194
- async ({ tableId, columnId, name, type, isNullable, isHidden, defaultValue, description, options }) => {
216
+ async ({ tableId, columnId, name, type, isNullable, isPublished, defaultValue, description, options }) => {
195
217
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
196
218
  if (!tableData) {
197
219
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
198
220
  }
199
221
 
200
222
  const columns = (tableData.columns || []).map(col => {
223
+ const { table, ...rest } = col;
201
224
  if (String(col.id) === String(columnId)) {
202
- const updated = { id: col.id };
203
- if (name !== undefined) updated.name = name;
204
- if (type !== undefined) updated.type = type;
205
- if (isNullable !== undefined) updated.isNullable = isNullable;
206
- if (isHidden !== undefined) updated.isHidden = isHidden;
207
- if (defaultValue !== undefined) updated.defaultValue = defaultValue;
208
- if (description !== undefined) updated.description = description;
209
- if (options !== undefined) updated.options = JSON.parse(options);
210
- return updated;
225
+ if (name !== undefined) rest.name = name;
226
+ if (type !== undefined) rest.type = type;
227
+ if (isNullable !== undefined) rest.isNullable = isNullable;
228
+ if (isPublished !== undefined) rest.isPublished = isPublished;
229
+ if (defaultValue !== undefined) rest.defaultValue = defaultValue;
230
+ if (description !== undefined) rest.description = description;
231
+ if (options !== undefined) rest.options = JSON.parse(options);
211
232
  }
212
- return { id: col.id };
233
+ return rest;
213
234
  });
214
235
 
215
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
216
- method: 'PATCH',
217
- body: JSON.stringify({ columns }),
218
- });
236
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
219
237
 
220
238
  return {
221
239
  content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -245,12 +263,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
245
263
 
246
264
  const columns = (tableData.columns || [])
247
265
  .filter(col => String(col.id) !== String(columnId))
248
- .map(col => ({ id: col.id }));
266
+ .map(({ table, ...col }) => col);
249
267
 
250
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
251
- method: 'PATCH',
252
- body: JSON.stringify({ columns }),
253
- });
268
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
254
269
 
255
270
  return {
256
271
  content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -277,11 +292,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
277
292
  onDelete: z.enum(['CASCADE', 'SET NULL', 'RESTRICT', 'NO ACTION']).optional().default('SET NULL').describe('On delete behavior.'),
278
293
  },
279
294
  async ({ sourceTableId, targetTableId, type, propertyName, inversePropertyName, isNullable, onDelete }) => {
280
- const relation = { type, propertyName, inversePropertyName: inversePropertyName || null, isNullable, onDelete };
281
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${sourceTableId}`, {
282
- method: 'PATCH',
283
- body: JSON.stringify({ relations: [{ targetTableId, ...relation }] }),
284
- });
295
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, sourceTableId);
296
+ if (!tableData) {
297
+ return { content: [{ type: 'text', text: `Error: Table with ID ${sourceTableId} not found.` }] };
298
+ }
299
+ const existingRelations = (tableData.relations || []).map(({ sourceTable, targetTable, ...rel }) => rel);
300
+ const newRelation = { targetTableId, type, propertyName, inversePropertyName: inversePropertyName || null, isNullable, onDelete };
301
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, sourceTableId, { relations: [...existingRelations, newRelation] });
285
302
  return {
286
303
  content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${sourceTableId} → ${targetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
287
304
  };
@@ -309,12 +326,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
309
326
 
310
327
  const relations = (tableData.relations || [])
311
328
  .filter(rel => String(rel.id) !== String(relationId))
312
- .map(rel => ({ id: rel.id }));
329
+ .map(({ sourceTable, targetTable, ...rel }) => rel);
313
330
 
314
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
315
- method: 'PATCH',
316
- body: JSON.stringify({ relations }),
317
- });
331
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { relations });
318
332
 
319
333
  return {
320
334
  content: [{ type: 'text', text: `Relation ${relationId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -333,8 +333,8 @@ server.tool(
333
333
  [
334
334
  'Create a post-hook that runs AFTER the handler. Use to transform responses or add metadata.',
335
335
  'Use `routeId` from `create_route` or `get_all_routes` — do not create a new table just to get a route id.',
336
- 'Macros: @DATA (handler result), @STATUS (HTTP status code), @BODY, @QUERY, @USER, @SHARE.',
337
- 'Must return a value that becomes the final response.',
336
+ 'Macros: @DATA, @STATUS, @ERROR, @BODY, @QUERY, @USER, @SHARE, @API (post-hooks always run; on error path @ERROR is set, @DATA is null).',
337
+ 'Mutate @DATA / $ctx.$data in place, or return a value: if the hook returns anything other than undefined, that value replaces $ctx.$data as the response payload.',
338
338
  ].join(' '),
339
339
  {
340
340
  routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),