@enfyra/mcp-server 0.0.13 → 0.0.14

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.13",
3
+ "version": "0.0.14",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,8 +33,14 @@ export function buildMcpServerInstructions(apiBaseUrl) {
33
33
  '- **Direct to Nest:** `http://localhost:1105` — no `/api` suffix on default Nest. Wrong: `http://localhost:1105/api/table_definition` (404) unless a proxy adds `/api`.',
34
34
  '- GraphQL: `{base}/graphql` and `{base}/graphql-schema` always share this same base.',
35
35
  '',
36
+ '### Routes vs tables (custom endpoints, handlers, hooks)',
37
+ '- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** with **`mainTableId`** of an **existing** table (resolve id via **`get_all_tables`**, **`get_all_metadata`**, or **`get_all_routes`**).',
38
+ '- **Wrong pattern:** calling **`create_table`** just to get an HTTP path, then overriding handlers on the **default** auto route `/{table_name}`. That adds unnecessary schema and breaks the usual CRUD surface for that table.',
39
+ '- **`create_table`** is only when the user needs **new persisted data** (new entity + columns). It is **not** the right tool when the goal is only a new path or custom script.',
40
+ '- **Right pattern:** **`create_route`** → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create). Same underlying table can have **multiple** routes (e.g. default CRUD at `/orders` and custom `/orders/stats` both pointing at `mainTable` orders).',
41
+ '',
36
42
  '### After a new table is created',
37
- '- Enfyra creates a route at `/{table_name}` using the table **name** from `create_table` (not the alias).',
43
+ '- Enfyra creates a **default** route at `/{table_name}` using the table **name** from `create_table` (not the alias). Prefer **`create_route`** for additional or custom paths instead of new tables.',
38
44
  '- **Four REST HTTP operations** on that resource:',
39
45
  ` - **GET** \`${getList}\` — list / filter (query: filter, sort, page, limit, fields, meta).`,
40
46
  ` - **POST** \`${getList}\` — create (JSON body).`,
@@ -102,8 +108,10 @@ export function buildMcpServerInstructions(apiBaseUrl) {
102
108
  '- **Event** (`websocket_event_definition`): `gateway` → gateway id, `eventName` (client emits), `handlerScript`, `timeout`, `isEnabled`.',
103
109
  '- **@SOCKET** in scripts: Connection handler — `@SOCKET.emit(event, data)` → this client; `@SOCKET.to(room).emit(event, data)` → room. Event handler — `@SOCKET.emit` → broadcast namespace; `@SOCKET.send` → this client; `@SOCKET.to(room).emit` → room.',
104
110
  '- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
111
+ '- **ACK + results (recommended UX):** client can emit an event with Socket.IO ack callback. Server immediately acks `{ queued: true, requestId, eventName }` (or `{ queued: false, error }`). The handler result is returned asynchronously via `ws:result` or `ws:error` with the same `requestId`.',
105
112
  '- **Client**: `io("<HTTP_ORIGIN>/namespace", {auth: {token: JWT}})`. Use the **origin where Socket.IO is served** (usually the **Nest** HTTP origin, e.g. `http://localhost:1105/chat` in local server-only setups). If Socket.IO is exposed only through the Nuxt app, use that host and your deployment’s WS path—**do not** assume port 3000 without checking `API_URL` / proxy config. Gateway `path` in metadata = Socket.IO **namespace**.',
106
113
  '- **Workflow**: Create gateway → `create_record` on `websocket_definition`. Create event → `create_record` on `websocket_event_definition` with `gateway: {id}`. Changes auto-reload; test handlers before saving.',
114
+ '- **Test WS handler (recommended):** `POST {base}/admin/test/run` with `{ kind:"websocket_event", gatewayPath, eventName, timeoutMs, payload, script }` to run a websocket event script without a real client. Returns `{ success, result, logs, emitted }`.',
107
115
  '',
108
116
  '### Flows (Automated Workflows)',
109
117
  '- Enfyra supports automated workflows via **`flow_definition`**, **`flow_step_definition`**, and **`flow_execution_definition`** tables.',
@@ -199,6 +207,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
199
207
  '- API testing is available at `/settings/api-tester` in the app UI.',
200
208
  '',
201
209
  '### MCP tool → HTTP',
210
+ '- **Routes:** `create_route` / `create_handler` / `create_pre_hook` / `create_post_hook` persist to `route_definition`, `route_handler_definition`, etc. (REST CRUD on those tables). Prefer **`create_route`** for new paths — not `create_table`.',
202
211
  `- \`get_all_metadata\` → GET \`${base}/metadata\``,
203
212
  `- \`get_table_metadata\` → GET \`${base}/metadata/<tableName>\``,
204
213
  `- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args)`,
@@ -39,9 +39,10 @@ export function registerTableTools(server, ENFYRA_API_URL) {
39
39
  'create_table',
40
40
  [
41
41
  'Create a new table definition with an auto-included `id` primary key column.',
42
+ '**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).',
42
43
  'Use create_column to add more columns after creation (columns are managed via cascade PATCH on table_definition, NOT via /column_definition).',
43
44
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
44
- 'Enfyra auto-creates a REST route at path `/<table_name>` (same segment as `name`, not alias).',
45
+ 'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
45
46
  '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).',
46
47
  '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.',
47
48
  `Full URLs: ${apiBase}/<table_name> (example table post: ${apiBase}/post).`,
@@ -177,7 +177,26 @@ server.tool('delete_record', 'Delete a record by ID', {
177
177
  // ROUTE & HANDLER TOOLS
178
178
  // ============================================================================
179
179
 
180
- server.tool('get_all_routes', 'Get all route definitions with handlers, hooks, and permissions', {
180
+ let _methodMap = null;
181
+ async function getMethodMap() {
182
+ if (_methodMap) return _methodMap;
183
+ const result = await fetchAPI(ENFYRA_API_URL, '/method_definition?limit=0');
184
+ _methodMap = {};
185
+ for (const m of result.data) {
186
+ _methodMap[m.method] = m.id || m._id;
187
+ }
188
+ return _methodMap;
189
+ }
190
+
191
+ function resolveMethodIds(methodMap, names) {
192
+ return names.map(m => {
193
+ const id = methodMap[m.toUpperCase()];
194
+ if (!id) throw new Error(`Unknown method "${m}". Valid: ${Object.keys(methodMap).join(', ')}`);
195
+ return { id };
196
+ });
197
+ }
198
+
199
+ server.tool('get_all_routes', 'List all route definitions (path, mainTable, handlers, hooks, permissions). Call before create_route to avoid duplicate paths and to pick routeId for hooks/handlers.', {
181
200
  includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
182
201
  }, async ({ includeDisabled }) => {
183
202
  const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
@@ -185,57 +204,168 @@ server.tool('get_all_routes', 'Get all route definitions with handlers, hooks, a
185
204
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
186
205
  });
187
206
 
188
- server.tool('create_route', 'Create a new route definition', {
189
- path: z.string().describe('Route path (e.g., "/api/users")'),
190
- method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'REST', 'GQL_QUERY', 'GQL_MUTATION']).describe('HTTP method'),
191
- tableId: z.string().describe('Main table ID for this route'),
192
- isEnabled: z.boolean().optional().default(true).describe('Enable route'),
193
- description: z.string().optional().describe('Route description'),
194
- }, async ({ path, method, tableId, isEnabled, description }) => {
195
- const result = await fetchAPI(ENFYRA_API_URL, '/route_definition', {
196
- method: 'POST',
197
- body: JSON.stringify({ path, method, tableId, isEnabled, description }),
198
- });
199
- return { content: [{ type: 'text', text: `Route created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
200
- });
207
+ server.tool(
208
+ 'create_route',
209
+ [
210
+ '**Use this when the user wants a new API route or path** — not `create_table`. A route links a URL path to an existing table (`mainTableId`) and sets HTTP/GQL methods.',
211
+ 'Do NOT create a new table_definition only to expose an endpoint; pick `mainTableId` from existing metadata unless the user explicitly needs new tables/columns.',
212
+ 'availableMethods = which verbs the route responds to. publishedMethods = which verbs are public (no auth).',
213
+ 'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
214
+ 'Flow: resolve table id → create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
215
+ ].join(' '),
216
+ {
217
+ path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
218
+ mainTableId: z.union([z.string(), z.number()]).describe('ID of the table_definition this route operates on. The route\'s $repos.main will query this table.'),
219
+ methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE', 'GQL_QUERY', 'GQL_MUTATION']))
220
+ .describe('HTTP/GQL methods this route supports (availableMethods). Common: ["GET","POST","PATCH","DELETE"]'),
221
+ publishedMethods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE', 'GQL_QUERY', 'GQL_MUTATION'])).optional()
222
+ .describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
223
+ isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
224
+ description: z.string().optional().describe('Route description'),
225
+ },
226
+ async ({ path: routePath, mainTableId, methods, publishedMethods, isEnabled, description }) => {
227
+ const methodMap = await getMethodMap();
201
228
 
202
- server.tool('create_handler', 'Create a handler for a route. Use template syntax: @BODY, @USER, #table_name, @THROW404', {
203
- routeId: z.string().describe('Route definition ID'),
204
- logic: z.string().describe('Handler logic (JavaScript code)'),
205
- timeout: z.number().optional().describe('Handler timeout in ms (default: 30000)'),
206
- }, async ({ routeId, logic, timeout }) => {
207
- const result = await fetchAPI(ENFYRA_API_URL, '/route_handler_definition', {
208
- method: 'POST',
209
- body: JSON.stringify({ routeId, logic, timeout: timeout || 30000 }),
210
- });
211
- return { content: [{ type: 'text', text: `Handler created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
212
- });
229
+ const body = {
230
+ path: routePath.startsWith('/') ? routePath : '/' + routePath,
231
+ mainTable: { id: mainTableId },
232
+ isEnabled,
233
+ description,
234
+ availableMethods: resolveMethodIds(methodMap, methods),
235
+ };
213
236
 
214
- server.tool('create_pre_hook', 'Create a pre-hook for a route. Use template syntax: @BODY, @QUERY, @USER', {
215
- routeId: z.string().describe('Route definition ID'),
216
- code: z.string().describe('Hook code (JavaScript)'),
217
- methods: z.array(z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])).optional().describe('Methods this hook applies to'),
218
- order: z.number().optional().default(0).describe('Hook execution order'),
219
- }, async ({ routeId, code, methods, order }) => {
220
- const result = await fetchAPI(ENFYRA_API_URL, '/pre_hook_definition', {
221
- method: 'POST',
222
- body: JSON.stringify({ routeId, code, methods: methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], order }),
223
- });
224
- return { content: [{ type: 'text', text: `Pre-hook created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
225
- });
237
+ if (publishedMethods && publishedMethods.length > 0) {
238
+ body.publishedMethods = resolveMethodIds(methodMap, publishedMethods);
239
+ }
226
240
 
227
- server.tool('create_post_hook', 'Create a post-hook for a route. Use template syntax: @DATA, @STATUS', {
228
- routeId: z.string().describe('Route definition ID'),
229
- code: z.string().describe('Hook code (JavaScript)'),
230
- methods: z.array(z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])).optional().describe('Methods this hook applies to'),
231
- order: z.number().optional().default(0).describe('Hook execution order'),
232
- }, async ({ routeId, code, methods, order }) => {
233
- const result = await fetchAPI(ENFYRA_API_URL, '/post_hook_definition', {
234
- method: 'POST',
235
- body: JSON.stringify({ routeId, code, methods: methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], order }),
236
- });
237
- return { content: [{ type: 'text', text: `Post-hook created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
238
- });
241
+ const result = await fetchAPI(ENFYRA_API_URL, '/route_definition', {
242
+ method: 'POST',
243
+ body: JSON.stringify(body),
244
+ });
245
+
246
+ await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
247
+
248
+ return { content: [{ type: 'text', text: `Route created (ID: ${result.id}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
249
+ },
250
+ );
251
+
252
+ server.tool(
253
+ 'create_handler',
254
+ [
255
+ 'Create a handler for a route+method. One handler per (route, method) pair.',
256
+ 'Attach to the route the user cares about (`get_all_routes`): typically a path from `create_route`, not a spurious table created only for handlers.',
257
+ 'Handler code runs inside a sandbox with $ctx. Use macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503, @SOCKET, @PKGS, @LOGS, @SHARE.',
258
+ 'Or use $ctx directly: $ctx.$body, $ctx.$repos.main.find(), $ctx.$helpers.$bcrypt.hash(), etc.',
259
+ 'require("pkg") works for installed Server packages. console.log() writes to $share.$logs.',
260
+ ].join(' '),
261
+ {
262
+ routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
263
+ methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE', 'GQL_QUERY', 'GQL_MUTATION']))
264
+ .describe('Methods to create handlers for. Creates one handler per method.'),
265
+ logic: z.string().describe('Handler JavaScript code'),
266
+ timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
267
+ },
268
+ async ({ routeId, methods, logic, timeout }) => {
269
+ const methodMap = await getMethodMap();
270
+ const results = [];
271
+
272
+ for (const method of methods) {
273
+ const methodId = methodMap[method.toUpperCase()];
274
+ if (!methodId) throw new Error(`Unknown method: ${method}. Valid: ${Object.keys(methodMap).join(', ')}`);
275
+
276
+ const body = { route: { id: routeId }, method: { id: methodId }, logic };
277
+ if (timeout) body.timeout = timeout;
278
+
279
+ const result = await fetchAPI(ENFYRA_API_URL, '/route_handler_definition', {
280
+ method: 'POST',
281
+ body: JSON.stringify(body),
282
+ });
283
+ results.push(result);
284
+ }
285
+
286
+ await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
287
+
288
+ return { content: [{ type: 'text', text: `Handler(s) created for [${methods.join(', ')}]. Routes reloaded.\n${JSON.stringify(results, null, 2)}` }] };
289
+ },
290
+ );
291
+
292
+ server.tool(
293
+ 'create_pre_hook',
294
+ [
295
+ 'Create a pre-hook that runs BEFORE the handler. Use to validate, transform, or inject data.',
296
+ 'Use `routeId` from `create_route` or `get_all_routes` — do not create a new table just to get a route id.',
297
+ 'Macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503.',
298
+ 'If the hook returns a value, that value becomes the response (handler is skipped).',
299
+ ].join(' '),
300
+ {
301
+ routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
302
+ name: z.string().describe('Hook name (unique per route)'),
303
+ code: z.string().describe('Hook JavaScript code'),
304
+ methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
305
+ .describe('Methods this hook applies to. Default: all REST methods.'),
306
+ priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
307
+ isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
308
+ },
309
+ async ({ routeId, name, code, methods, priority, isEnabled }) => {
310
+ const methodMap = await getMethodMap();
311
+ const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
312
+
313
+ const result = await fetchAPI(ENFYRA_API_URL, '/pre_hook_definition', {
314
+ method: 'POST',
315
+ body: JSON.stringify({
316
+ route: { id: routeId },
317
+ name,
318
+ code,
319
+ methods: resolveMethodIds(methodMap, methodNames),
320
+ priority,
321
+ isEnabled,
322
+ }),
323
+ });
324
+
325
+ await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
326
+
327
+ return { content: [{ type: 'text', text: `Pre-hook "${name}" created (ID: ${result.id}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
328
+ },
329
+ );
330
+
331
+ server.tool(
332
+ 'create_post_hook',
333
+ [
334
+ 'Create a post-hook that runs AFTER the handler. Use to transform responses or add metadata.',
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.',
338
+ ].join(' '),
339
+ {
340
+ routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
341
+ name: z.string().describe('Hook name (unique per route)'),
342
+ code: z.string().describe('Hook JavaScript code'),
343
+ methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
344
+ .describe('Methods this hook applies to. Default: all REST methods.'),
345
+ priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
346
+ isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
347
+ },
348
+ async ({ routeId, name, code, methods, priority, isEnabled }) => {
349
+ const methodMap = await getMethodMap();
350
+ const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
351
+
352
+ const result = await fetchAPI(ENFYRA_API_URL, '/post_hook_definition', {
353
+ method: 'POST',
354
+ body: JSON.stringify({
355
+ route: { id: routeId },
356
+ name,
357
+ code,
358
+ methods: resolveMethodIds(methodMap, methodNames),
359
+ priority,
360
+ isEnabled,
361
+ }),
362
+ });
363
+
364
+ await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
365
+
366
+ return { content: [{ type: 'text', text: `Post-hook "${name}" created (ID: ${result.id}). Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
367
+ },
368
+ );
239
369
 
240
370
  // Register table tools
241
371
  registerTableTools(server, ENFYRA_API_URL);
@@ -410,8 +540,8 @@ server.tool(
410
540
  pkgDescription = exactMatch.package.description || '';
411
541
  }
412
542
 
413
- // Step 2: Check if already installed
414
- const checkFilter = JSON.stringify({ name: { _eq: name } });
543
+ // Step 2: Check if already installed (same name AND type)
544
+ const checkFilter = JSON.stringify({ name: { _eq: name }, type: { _eq: type } });
415
545
  const existing = await fetchAPI(ENFYRA_API_URL, `/package_definition?filter=${encodeURIComponent(checkFilter)}&limit=1`);
416
546
  if (existing.data && existing.data.length > 0) {
417
547
  return {
@@ -456,7 +586,7 @@ server.tool(
456
586
 
457
587
  server.tool('create_menu', 'Create a menu item in the navigation', {
458
588
  label: z.string().describe('Menu label'),
459
- type: z.enum(['separator', 'link', 'route', 'dropdown', 'widget', 'extension']).describe('Menu type'),
589
+ type: z.enum(['Menu', 'Dropdown Menu']).default('Menu').describe('Menu type: "Menu" for leaf items, "Dropdown Menu" for items with children'),
460
590
  icon: z.string().optional().describe('Lucide icon name'),
461
591
  path: z.string().optional().describe('Route path for type=route'),
462
592
  externalUrl: z.string().optional().describe('External URL for type=link'),
@@ -464,7 +594,11 @@ server.tool('create_menu', 'Create a menu item in the navigation', {
464
594
  isEnabled: z.boolean().optional().default(true).describe('Enable menu'),
465
595
  description: z.string().optional().describe('Menu description'),
466
596
  }, async (data) => {
467
- const result = await fetchAPI(ENFYRA_API_URL, '/menu_definition', { method: 'POST', body: JSON.stringify(data) });
597
+ const body = { ...data };
598
+ if (body.path && !body.path.startsWith('/')) {
599
+ body.path = '/' + body.path;
600
+ }
601
+ const result = await fetchAPI(ENFYRA_API_URL, '/menu_definition', { method: 'POST', body: JSON.stringify(body) });
468
602
  return { content: [{ type: 'text', text: `Menu created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
469
603
  });
470
604
 
@@ -481,6 +615,7 @@ server.tool(
481
615
  menuId: z.string().optional().describe('Required for type=page — menu_definition id from create_menu. Omit for widget'),
482
616
  isEnabled: z.boolean().optional().default(true).describe('Enable extension'),
483
617
  description: z.string().optional().describe('Extension description'),
618
+ version: z.string().optional().default('1.0.0').describe('Extension version'),
484
619
  },
485
620
  async (data) => {
486
621
  const body = { ...data };