@enfyra/mcp-server 0.0.11 → 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.
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Enfyra MCP — stdio server (loaded by index.mjs).
3
+ */
4
+
5
+ import { config } from 'dotenv';
6
+ config();
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { z } from 'zod';
11
+
12
+ // Configuration
13
+ const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
14
+ const ENFYRA_EMAIL = process.env.ENFYRA_EMAIL || '';
15
+ const ENFYRA_PASSWORD = process.env.ENFYRA_PASSWORD || '';
16
+
17
+ // Import modules
18
+ import { login, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
19
+ import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
20
+ import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
21
+ import { registerTableTools } from './lib/table-tools.js';
22
+
23
+ // Initialize auth module
24
+ initAuth(ENFYRA_API_URL, ENFYRA_EMAIL, ENFYRA_PASSWORD);
25
+
26
+ // Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
27
+ const server = new McpServer(
28
+ {
29
+ name: 'enfyra-mcp',
30
+ version: '1.0.0',
31
+ },
32
+ {
33
+ instructions: buildMcpServerInstructions(ENFYRA_API_URL),
34
+ },
35
+ );
36
+
37
+ // ============================================================================
38
+ // METADATA TOOLS
39
+ // ============================================================================
40
+
41
+ server.tool('get_all_metadata', 'Get all metadata (tables, columns, relations, routes, hooks, etc.) from Enfyra', {}, async () => {
42
+ const result = await fetchAPI(ENFYRA_API_URL, '/metadata');
43
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
44
+ });
45
+
46
+ server.tool('get_table_metadata', 'Get metadata for a specific table by name', {
47
+ tableName: z.string().describe('Table name (e.g., "user_definition", "route_definition")'),
48
+ }, async ({ tableName }) => {
49
+ const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
50
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
51
+ });
52
+
53
+ // ============================================================================
54
+ // QUERY TOOLS
55
+ // ============================================================================
56
+
57
+ server.tool(
58
+ 'get_enfyra_api_context',
59
+ [
60
+ 'Returns the resolved API base URL for this MCP session (env ENFYRA_API_URL).',
61
+ 'Use when the user asks which HTTP endpoint or full URL applies: combine enfyraApiUrl with paths from server instructions (GET/POST /{table}, PATCH/DELETE /{table}/{id}, no GET /{table}/{id}).',
62
+ 'Auth: publishedMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
63
+ 'If path might differ from table name, use get_all_routes before asserting a URL.',
64
+ 'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
65
+ 'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; GQL_QUERY vs GQL_MUTATION in publishedMethods — server instructions.',
66
+ ].join(' '),
67
+ {},
68
+ async () => {
69
+ const base = ENFYRA_API_URL.replace(/\/$/, '');
70
+ const gql = buildGraphqlUrls(ENFYRA_API_URL);
71
+ const payload = {
72
+ enfyraApiUrl: base,
73
+ graphqlHttpUrl: gql.graphqlHttpUrl,
74
+ graphqlSchemaUrl: gql.graphqlSchemaUrl,
75
+ examples: {
76
+ listOrCreate: `${base}/<table_name>`,
77
+ updateOrDelete: `${base}/<table_name>/<id>`,
78
+ oneRowById: `${base}/<table_name>?filter={"id":{"_eq":"<id>"}}&limit=1`,
79
+ },
80
+ auth: {
81
+ publishedMethods: 'If the HTTP method is published for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
82
+ mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
83
+ },
84
+ pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
85
+ note: 'Full tool→HTTP mapping is in MCP server instructions (shown to the model at connect).',
86
+ };
87
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
88
+ },
89
+ );
90
+
91
+ server.tool('query_table', 'Query any table in Enfyra with filters, sorting, and pagination', {
92
+ tableName: z.string().describe('Table name to query'),
93
+ filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
94
+ sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
95
+ page: z.number().optional().describe('Page number (default: 1)'),
96
+ limit: z.number().optional().describe('Items per page (default: 50, max: 500)'),
97
+ fields: z.array(z.string()).optional().describe('Fields to select'),
98
+ }, async ({ tableName, filter, sort, page, limit, fields }) => {
99
+ validateTableName(tableName);
100
+ validateFilter(filter);
101
+
102
+ const queryParams = new URLSearchParams();
103
+ if (filter) queryParams.set('filter', filter);
104
+ if (sort) queryParams.set('sort', sort);
105
+ if (page) queryParams.set('page', String(page));
106
+ if (limit) queryParams.set('limit', String(limit));
107
+ if (fields) queryParams.set('fields', fields.join(','));
108
+
109
+ const query = queryParams.toString();
110
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}${query ? `?${query}` : ''}`);
111
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
112
+ });
113
+
114
+ server.tool(
115
+ 'find_one_record',
116
+ 'Find a single record by ID or filter. By ID uses GET with filter (Enfyra has no GET /table/:id route).',
117
+ {
118
+ tableName: z.string().describe('Table name'),
119
+ id: z.string().optional().describe('Record ID'),
120
+ filter: z.string().optional().describe('Filter as JSON string to find by'),
121
+ },
122
+ async ({ tableName, id, filter }) => {
123
+ validateTableName(tableName);
124
+ if (id) {
125
+ // Enfyra route engine does not register GET /<table>/:id (only PATCH/DELETE use /:id). Use list + filter.
126
+ const filterObj = JSON.stringify({ id: { _eq: id } });
127
+ const result = await fetchAPI(
128
+ ENFYRA_API_URL,
129
+ `/${tableName}?filter=${encodeURIComponent(filterObj)}&limit=1`,
130
+ );
131
+ const one = result.data?.[0] ?? null;
132
+ return { content: [{ type: 'text', text: JSON.stringify(one, null, 2) }] };
133
+ }
134
+ if (!filter) throw new Error('Provide id or filter');
135
+ validateFilter(filter);
136
+ const result = await fetchAPI(
137
+ ENFYRA_API_URL,
138
+ `/${tableName}?filter=${encodeURIComponent(filter)}&limit=1`,
139
+ );
140
+ return { content: [{ type: 'text', text: JSON.stringify(result.data?.[0] || null, null, 2) }] };
141
+ },
142
+ );
143
+
144
+ // ============================================================================
145
+ // CRUD TOOLS
146
+ // ============================================================================
147
+
148
+ server.tool('create_record', 'Create a new record in any table', {
149
+ tableName: z.string().describe('Table name to insert into'),
150
+ data: z.string().describe('Record data as JSON string'),
151
+ }, async ({ tableName, data }) => {
152
+ validateTableName(tableName);
153
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}`, { method: 'POST', body: data });
154
+ return { content: [{ type: 'text', text: `Record created:\n${JSON.stringify(result, null, 2)}` }] };
155
+ });
156
+
157
+ server.tool('update_record', 'Update an existing record by ID using PATCH', {
158
+ tableName: z.string().describe('Table name'),
159
+ id: z.string().describe('Record ID to update'),
160
+ data: z.string().describe('Fields to update as JSON string'),
161
+ }, async ({ tableName, id, data }) => {
162
+ validateTableName(tableName);
163
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'PATCH', body: data });
164
+ return { content: [{ type: 'text', text: `Record updated:\n${JSON.stringify(result, null, 2)}` }] };
165
+ });
166
+
167
+ server.tool('delete_record', 'Delete a record by ID', {
168
+ tableName: z.string().describe('Table name'),
169
+ id: z.string().describe('Record ID to delete'),
170
+ }, async ({ tableName, id }) => {
171
+ validateTableName(tableName);
172
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'DELETE' });
173
+ return { content: [{ type: 'text', text: `Record deleted:\n${JSON.stringify(result, null, 2)}` }] };
174
+ });
175
+
176
+ // ============================================================================
177
+ // ROUTE & HANDLER TOOLS
178
+ // ============================================================================
179
+
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.', {
200
+ includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
201
+ }, async ({ includeDisabled }) => {
202
+ const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
203
+ const result = await fetchAPI(ENFYRA_API_URL, `/route_definition?filter=${encodeURIComponent(JSON.stringify(filter))}&limit=500`);
204
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
205
+ });
206
+
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();
228
+
229
+ const body = {
230
+ path: routePath.startsWith('/') ? routePath : '/' + routePath,
231
+ mainTable: { id: mainTableId },
232
+ isEnabled,
233
+ description,
234
+ availableMethods: resolveMethodIds(methodMap, methods),
235
+ };
236
+
237
+ if (publishedMethods && publishedMethods.length > 0) {
238
+ body.publishedMethods = resolveMethodIds(methodMap, publishedMethods);
239
+ }
240
+
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
+ );
369
+
370
+ // Register table tools
371
+ registerTableTools(server, ENFYRA_API_URL);
372
+
373
+ // ============================================================================
374
+ // CACHE & SYSTEM TOOLS
375
+ // ============================================================================
376
+
377
+ server.tool('reload_all', 'Reload all caches (metadata, routes, swagger, GraphQL)', {}, async () => {
378
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload', { method: 'POST' });
379
+ return { content: [{ type: 'text', text: `System reloaded:\n${JSON.stringify(result, null, 2)}` }] };
380
+ });
381
+
382
+ server.tool('reload_metadata', 'Reload metadata cache only', {}, async () => {
383
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' });
384
+ return { content: [{ type: 'text', text: `Metadata reloaded:\n${JSON.stringify(result, null, 2)}` }] };
385
+ });
386
+
387
+ server.tool('reload_routes', 'Reload routes cache only', {}, async () => {
388
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
389
+ return { content: [{ type: 'text', text: `Routes reloaded:\n${JSON.stringify(result, null, 2)}` }] };
390
+ });
391
+
392
+ server.tool('reload_swagger', 'Reload Swagger/OpenAPI spec', {}, async () => {
393
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/swagger', { method: 'POST' });
394
+ return { content: [{ type: 'text', text: `Swagger reloaded:\n${JSON.stringify(result, null, 2)}` }] };
395
+ });
396
+
397
+ server.tool('reload_graphql', 'Reload GraphQL schema', {}, async () => {
398
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/graphql', { method: 'POST' });
399
+ return { content: [{ type: 'text', text: `GraphQL reloaded:\n${JSON.stringify(result, null, 2)}` }] };
400
+ });
401
+
402
+ // ============================================================================
403
+ // LOGS TOOLS
404
+ // ============================================================================
405
+
406
+ server.tool('get_log_files', 'List available log files and stats', {}, async () => {
407
+ const result = await fetchAPI(ENFYRA_API_URL, '/logs');
408
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
409
+ });
410
+
411
+ server.tool('get_log_content', 'Get content of a specific log file', {
412
+ filename: z.string().describe('Log file name'),
413
+ page: z.number().optional().default(1).describe('Page number'),
414
+ pageSize: z.number().optional().default(100).describe('Lines per page'),
415
+ filter: z.string().optional().describe('Text filter'),
416
+ level: z.string().optional().describe('Log level filter (INFO, WARN, ERROR)'),
417
+ }, async ({ filename, page, pageSize, filter, level }) => {
418
+ const queryParams = new URLSearchParams();
419
+ if (page) queryParams.set('page', String(page));
420
+ if (pageSize) queryParams.set('pageSize', String(pageSize));
421
+ if (filter) queryParams.set('filter', filter);
422
+ if (level) queryParams.set('level', level);
423
+ const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`);
424
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
425
+ });
426
+
427
+ server.tool('tail_log', 'Get last N lines from a log file', {
428
+ filename: z.string().describe('Log file name'),
429
+ lines: z.number().optional().default(50).describe('Number of lines to retrieve'),
430
+ }, async ({ filename, lines }) => {
431
+ const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}/tail?lines=${lines}`);
432
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
433
+ });
434
+
435
+ server.tool('search_logs', 'Search for ERROR or WARN logs across recent log files', {
436
+ level: z.enum(['ERROR', 'WARN', 'INFO']).optional().default('ERROR').describe('Log level'),
437
+ keyword: z.string().optional().describe('Keyword to filter logs'),
438
+ limit: z.number().optional().default(50).describe('Max results per level'),
439
+ }, async ({ level, keyword, limit }) => {
440
+ const logFilesResult = await fetchAPI(ENFYRA_API_URL, '/logs');
441
+ const logFiles = logFilesResult.files || [];
442
+ const recentFiles = logFiles.filter(f => f.name.includes('app-') || f.name.includes('error-'));
443
+ const results = [];
444
+ for (const file of recentFiles.slice(0, 3)) {
445
+ try {
446
+ const contentResult = await fetchAPI(ENFYRA_API_URL, `/logs/${file.name}?level=${level}&pageSize=${limit}`);
447
+ const lines = contentResult.lines || contentResult.data || [];
448
+ const filteredLines = keyword ? lines.filter(l => JSON.stringify(l).toLowerCase().includes(keyword.toLowerCase())) : lines;
449
+ if (filteredLines.length > 0) results.push({ file: file.name, level, logs: filteredLines });
450
+ } catch (e) { /* skip */ }
451
+ }
452
+ return { content: [{ type: 'text', text: `Found ${results.length} files:\n${JSON.stringify(results, null, 2)}` }] };
453
+ });
454
+
455
+ // ============================================================================
456
+ // AUTH & USER TOOLS
457
+ // ============================================================================
458
+
459
+ server.tool('get_current_user', 'Get current authenticated user info', {}, async () => {
460
+ const result = await fetchAPI(ENFYRA_API_URL, '/me');
461
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
462
+ });
463
+
464
+ server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
465
+ const result = await fetchAPI(ENFYRA_API_URL, '/role_definition?limit=100');
466
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
467
+ });
468
+
469
+ server.tool('login', 'Force login to Enfyra and get new tokens', {
470
+ email: z.string().email().optional().describe('Admin email'),
471
+ password: z.string().optional().describe('Password'),
472
+ }, async ({ email, password }) => {
473
+ const loginEmail = email || ENFYRA_EMAIL;
474
+ const loginPassword = password || ENFYRA_PASSWORD;
475
+ if (!loginEmail || !loginPassword) throw new Error('Email and password required');
476
+ await login(ENFYRA_API_URL, loginEmail, loginPassword);
477
+ return { content: [{ type: 'text', text: `Logged in successfully!\nToken expires: ${new Date(getTokenExpiry()).toISOString()}` }] };
478
+ });
479
+
480
+ // ============================================================================
481
+ // PACKAGE TOOLS
482
+ // ============================================================================
483
+
484
+ server.tool(
485
+ 'search_npm',
486
+ 'Search NPM registry for packages. Returns name, version, description for installation.',
487
+ {
488
+ query: z.string().describe('Package name or search term (e.g., "axios", "node-ssh", "dayjs")'),
489
+ limit: z.number().optional().default(5).describe('Max results (default: 5)'),
490
+ },
491
+ async ({ query, limit }) => {
492
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`;
493
+ const response = await fetch(url);
494
+ if (!response.ok) throw new Error(`NPM search failed: ${response.statusText}`);
495
+ const data = await response.json();
496
+
497
+ const packages = data.objects.map((obj) => ({
498
+ name: obj.package.name,
499
+ version: obj.package.version,
500
+ description: obj.package.description || '',
501
+ }));
502
+
503
+ return {
504
+ content: [{
505
+ type: 'text',
506
+ text: JSON.stringify({ packages, total: data.total }, null, 2),
507
+ }],
508
+ };
509
+ },
510
+ );
511
+
512
+ server.tool(
513
+ 'install_package',
514
+ [
515
+ 'Install an NPM package on Enfyra. Searches NPM registry for exact version, then creates package_definition record.',
516
+ 'Enfyra handles the actual yarn add internally based on type.',
517
+ 'Type "Server" = available in handlers/hooks as $ctx.$pkgs.packageName.',
518
+ 'Type "App" = available in extensions via getPackages().',
519
+ ].join(' '),
520
+ {
521
+ name: z.string().describe('Exact NPM package name (e.g., "node-ssh", "axios")'),
522
+ type: z.enum(['Server', 'App']).default('Server').describe('Where to install: Server (handlers/hooks) or App (extensions)'),
523
+ version: z.string().optional().describe('Specific version. If omitted, fetches latest from NPM.'),
524
+ },
525
+ async ({ name, type, version }) => {
526
+ // Step 1: Get package info from NPM if version not specified
527
+ let pkgVersion = version;
528
+ let pkgDescription = '';
529
+
530
+ if (!pkgVersion) {
531
+ const npmUrl = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(name)}&size=5`;
532
+ const npmResponse = await fetch(npmUrl);
533
+ if (!npmResponse.ok) throw new Error(`NPM search failed: ${npmResponse.statusText}`);
534
+ const npmData = await npmResponse.json();
535
+
536
+ const exactMatch = npmData.objects.find((obj) => obj.package.name === name);
537
+ if (!exactMatch) throw new Error(`Package "${name}" not found on NPM`);
538
+
539
+ pkgVersion = exactMatch.package.version;
540
+ pkgDescription = exactMatch.package.description || '';
541
+ }
542
+
543
+ // Step 2: Check if already installed (same name AND type)
544
+ const checkFilter = JSON.stringify({ name: { _eq: name }, type: { _eq: type } });
545
+ const existing = await fetchAPI(ENFYRA_API_URL, `/package_definition?filter=${encodeURIComponent(checkFilter)}&limit=1`);
546
+ if (existing.data && existing.data.length > 0) {
547
+ return {
548
+ content: [{
549
+ type: 'text',
550
+ text: `Package "${name}" is already installed (version: ${existing.data[0].version}, type: ${existing.data[0].type}).\n${JSON.stringify(existing.data[0], null, 2)}`,
551
+ }],
552
+ };
553
+ }
554
+
555
+ // Step 3: Get current user for installedBy
556
+ const me = await fetchAPI(ENFYRA_API_URL, '/me');
557
+ const userId = me.data?.[0]?.id || me.data?.[0]?._id;
558
+ if (!userId) throw new Error('Cannot get current user ID');
559
+
560
+ // Step 4: Install via package_definition
561
+ const body = {
562
+ name,
563
+ version: pkgVersion,
564
+ description: pkgDescription,
565
+ type,
566
+ installedBy: { id: userId },
567
+ };
568
+
569
+ const result = await fetchAPI(ENFYRA_API_URL, '/package_definition', {
570
+ method: 'POST',
571
+ body: JSON.stringify(body),
572
+ });
573
+
574
+ return {
575
+ content: [{
576
+ type: 'text',
577
+ text: `Package "${name}@${pkgVersion}" installed successfully (type: ${type}).\n${JSON.stringify(result, null, 2)}`,
578
+ }],
579
+ };
580
+ },
581
+ );
582
+
583
+ // ============================================================================
584
+ // MENU & EXTENSION TOOLS
585
+ // ============================================================================
586
+
587
+ server.tool('create_menu', 'Create a menu item in the navigation', {
588
+ label: z.string().describe('Menu label'),
589
+ type: z.enum(['Menu', 'Dropdown Menu']).default('Menu').describe('Menu type: "Menu" for leaf items, "Dropdown Menu" for items with children'),
590
+ icon: z.string().optional().describe('Lucide icon name'),
591
+ path: z.string().optional().describe('Route path for type=route'),
592
+ externalUrl: z.string().optional().describe('External URL for type=link'),
593
+ order: z.number().optional().default(0).describe('Display order'),
594
+ isEnabled: z.boolean().optional().default(true).describe('Enable menu'),
595
+ description: z.string().optional().describe('Menu description'),
596
+ }, async (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) });
602
+ return { content: [{ type: 'text', text: `Menu created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
603
+ });
604
+
605
+ server.tool(
606
+ 'create_extension',
607
+ [
608
+ 'Create an extension (Vue SFC page or widget). Code must be Vue SFC: <template>...</template> + <script setup>...</script> — NO imports, use globals (ref, useToast, useApi, UButton, etc).',
609
+ 'For type=page: create menu first (create_menu), get id, then pass menuId. For type=widget no menu needed. Server auto-compiles; tell user to refresh (F5) after create. See extension rules in MCP instructions.',
610
+ ].join(' '),
611
+ {
612
+ name: z.string().describe('Extension name (unique)'),
613
+ type: z.enum(['page', 'widget']).describe('Extension type: page = full page linked to menu; widget = embed via Widget component'),
614
+ code: z.string().describe('Vue SFC string — <template> + <script setup>, NO import statements'),
615
+ menuId: z.string().optional().describe('Required for type=page — menu_definition id from create_menu. Omit for widget'),
616
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension'),
617
+ description: z.string().optional().describe('Extension description'),
618
+ version: z.string().optional().default('1.0.0').describe('Extension version'),
619
+ },
620
+ async (data) => {
621
+ const body = { ...data };
622
+ if (body.menuId) {
623
+ body.menu = { id: body.menuId };
624
+ delete body.menuId;
625
+ }
626
+ const result = await fetchAPI(ENFYRA_API_URL, '/extension_definition', { method: 'POST', body: JSON.stringify(body) });
627
+ return { content: [{ type: 'text', text: `Extension created (ID: ${result.id}). Tell user to refresh (F5) to see it.\n${JSON.stringify(result, null, 2)}` }] };
628
+ },
629
+ );
630
+
631
+ // ============================================================================
632
+ // MAIN
633
+ // ============================================================================
634
+
635
+ async function main() {
636
+ console.error('Starting Enfyra MCP Server...');
637
+ console.error(`API URL: ${ENFYRA_API_URL}`);
638
+ console.error(`Auth: ${ENFYRA_EMAIL ? `Configured (${ENFYRA_EMAIL})` : 'Not configured'}`);
639
+
640
+ const transport = new StdioServerTransport();
641
+ await server.connect(transport);
642
+
643
+ console.error('Enfyra MCP Server running on stdio');
644
+ }
645
+
646
+ main().catch((error) => {
647
+ console.error('Fatal error:', error);
648
+ process.exit(1);
649
+ });