@enfyra/mcp-server 0.0.1

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/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Enfyra MCP Server
2
+
3
+ MCP (Model Context Protocol) server for managing Enfyra instances via Claude Code.
4
+
5
+ ## Features
6
+
7
+ - **Auto Token Refresh**: Automatically obtains and refreshes JWT tokens
8
+ - **Metadata Management**: Query tables, columns, relations, routes, hooks
9
+ - **CRUD Operations**: Create, read, update, delete records in any table
10
+ - **Route & Handler Management**: Create routes, handlers, pre/post hooks
11
+ - **Table Management**: Create tables and columns
12
+ - **Cache Control**: Reload metadata, routes, Swagger, GraphQL
13
+ - **Log Access**: View and tail log files
14
+
15
+ ## Add to Claude Code
16
+
17
+ ### Method 1: Global Configuration (Recommended)
18
+
19
+ Edit `~/.claude.json` and add to the `mcpServers` section:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "enfyra": {
25
+ "command": "npx",
26
+ "args": ["-y", "@enfyra/mcp-server"],
27
+ "env": {
28
+ "ENFYRA_API_URL": "http://localhost:3000/api",
29
+ "ENFYRA_EMAIL": "your-email@example.com",
30
+ "ENFYRA_PASSWORD": "your-password"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ **Important**:
38
+ - The `-y` flag automatically confirms installation
39
+ - After updating config, restart Claude Code for changes to take effect
40
+
41
+ | Variable | Description | Default |
42
+ |----------|-------------|---------|
43
+ | `ENFYRA_API_URL` | Base URL of Enfyra API | `http://localhost:3000/api` |
44
+ | `ENFYRA_EMAIL` | Admin email for authentication | - |
45
+ | `ENFYRA_PASSWORD` | Admin password for authentication | - |
46
+
47
+ ## Available Tools
48
+
49
+ ### Authentication
50
+ - `login` - Login and get new access token
51
+
52
+ ### Metadata
53
+ - `get_all_metadata` - Get all system metadata
54
+ - `get_table_metadata` - Get metadata for a specific table
55
+ - `query_table` - Query any table with filters
56
+ - `find_one_record` - Find a single record
57
+
58
+ ### CRUD
59
+ - `create_record` - Create a new record
60
+ - `update_record` - Update a record
61
+ - `delete_record` - Delete a record
62
+
63
+ ### Routes & Handlers
64
+ - `get_all_routes` - Get all route definitions
65
+ - `create_route` - Create a new route
66
+ - `create_handler` - Create a handler for a route
67
+ - `create_pre_hook` - Create a pre-hook
68
+ - `create_post_hook` - Create a post-hook
69
+
70
+ ### Tables
71
+ - `get_all_tables` - Get all table definitions
72
+ - `create_table` - Create a new table
73
+ - `create_column` - Create a column
74
+ - `sync_table_schema` - Sync DB schema with metadata
75
+
76
+ ### System
77
+ - `reload_all` - Reload all caches
78
+ - `reload_metadata` - Reload metadata only
79
+ - `reload_routes` - Reload routes only
80
+ - `reload_swagger` - Reload Swagger spec
81
+ - `reload_graphql` - Reload GraphQL schema
82
+
83
+ ### Logs
84
+ - `get_log_files` - List available log files
85
+ - `get_log_content` - Get log file content
86
+ - `tail_log` - Get last N lines from log
87
+
88
+ ### Auth
89
+ - `get_current_user` - Get current user info
90
+ - `get_all_roles` - Get all roles
91
+
92
+ ## Usage Examples
93
+
94
+ After configuring in Claude Code:
95
+
96
+ ```
97
+ "Query all users"
98
+ → Uses query_table with tableName="user_definition"
99
+
100
+ "Create a new API route for /api/tasks"
101
+ → Uses create_route, then create_handler
102
+
103
+ "Debug the error logs"
104
+ → Uses tail_log with filename="error.log"
105
+
106
+ "Deploy my metadata changes"
107
+ → Uses reload_all
108
+ ```
109
+
110
+ ## Security Notes
111
+
112
+ - All operations go through Enfyra's REST API
113
+ - Authentication is required (JWT token with auto-refresh)
114
+ - Permissions are enforced by Enfyra's auth system
115
+ - Pre/Post hooks and RLS are applied to all queries
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@enfyra/mcp-server",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "src/index.mjs",
8
+ "bin": "src/index.mjs",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "start": "node src/index.mjs",
14
+ "dev": "node --watch src/index.mjs"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.0.0",
18
+ "dotenv": "^17.3.1",
19
+ "zod": "^3.24.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Enfyra MCP Server - Main Entry Point
4
+ *
5
+ * Provides tools to manage Enfyra instance via Claude Code.
6
+ * All operations go through Enfyra's REST API.
7
+ */
8
+
9
+ import { config } from 'dotenv';
10
+ config();
11
+
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { z } from 'zod';
15
+
16
+ // Configuration
17
+ const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
18
+ const ENFYRA_EMAIL = process.env.ENFYRA_EMAIL || '';
19
+ const ENFYRA_PASSWORD = process.env.ENFYRA_PASSWORD || '';
20
+
21
+ // Import modules
22
+ import { login, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
23
+ import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
24
+ import { registerTableTools } from './lib/table-tools.js';
25
+
26
+ // Initialize auth module
27
+ initAuth(ENFYRA_API_URL, ENFYRA_EMAIL, ENFYRA_PASSWORD);
28
+
29
+ // Create MCP server
30
+ const server = new McpServer({
31
+ name: 'enfyra-mcp',
32
+ version: '1.0.0',
33
+ });
34
+
35
+ // ============================================================================
36
+ // METADATA TOOLS
37
+ // ============================================================================
38
+
39
+ server.tool('get_all_metadata', 'Get all metadata (tables, columns, relations, routes, hooks, etc.) from Enfyra', {}, async () => {
40
+ const result = await fetchAPI(ENFYRA_API_URL, '/metadata');
41
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
42
+ });
43
+
44
+ server.tool('get_table_metadata', 'Get metadata for a specific table by name', {
45
+ tableName: z.string().describe('Table name (e.g., "user_definition", "route_definition")'),
46
+ }, async ({ tableName }) => {
47
+ const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
48
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
49
+ });
50
+
51
+ // ============================================================================
52
+ // QUERY TOOLS
53
+ // ============================================================================
54
+
55
+ server.tool('query_table', 'Query any table in Enfyra with filters, sorting, and pagination', {
56
+ tableName: z.string().describe('Table name to query'),
57
+ filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
58
+ sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
59
+ page: z.number().optional().describe('Page number (default: 1)'),
60
+ limit: z.number().optional().describe('Items per page (default: 50, max: 500)'),
61
+ fields: z.array(z.string()).optional().describe('Fields to select'),
62
+ }, async ({ tableName, filter, sort, page, limit, fields }) => {
63
+ validateTableName(tableName);
64
+ validateFilter(filter);
65
+
66
+ const queryParams = new URLSearchParams();
67
+ if (filter) queryParams.set('filter', filter);
68
+ if (sort) queryParams.set('sort', sort);
69
+ if (page) queryParams.set('page', String(page));
70
+ if (limit) queryParams.set('limit', String(limit));
71
+ if (fields) queryParams.set('fields', fields.join(','));
72
+
73
+ const query = queryParams.toString();
74
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}${query ? `?${query}` : ''}`);
75
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
76
+ });
77
+
78
+ server.tool('find_one_record', 'Find a single record by ID or filter', {
79
+ tableName: z.string().describe('Table name'),
80
+ id: z.string().optional().describe('Record ID'),
81
+ filter: z.string().optional().describe('Filter as JSON string to find by'),
82
+ }, async ({ tableName, id, filter }) => {
83
+ validateTableName(tableName);
84
+ if (id) {
85
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`);
86
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
87
+ }
88
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?filter=${filter}&limit=1`);
89
+ return { content: [{ type: 'text', text: JSON.stringify(result.data?.[0] || null, null, 2) }] };
90
+ });
91
+
92
+ // ============================================================================
93
+ // CRUD TOOLS
94
+ // ============================================================================
95
+
96
+ server.tool('create_record', 'Create a new record in any table', {
97
+ tableName: z.string().describe('Table name to insert into'),
98
+ data: z.string().describe('Record data as JSON string'),
99
+ }, async ({ tableName, data }) => {
100
+ validateTableName(tableName);
101
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}`, { method: 'POST', body: data });
102
+ return { content: [{ type: 'text', text: `Record created:\n${JSON.stringify(result, null, 2)}` }] };
103
+ });
104
+
105
+ server.tool('update_record', 'Update an existing record by ID using PATCH', {
106
+ tableName: z.string().describe('Table name'),
107
+ id: z.string().describe('Record ID to update'),
108
+ data: z.string().describe('Fields to update as JSON string'),
109
+ }, async ({ tableName, id, data }) => {
110
+ validateTableName(tableName);
111
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'PATCH', body: data });
112
+ return { content: [{ type: 'text', text: `Record updated:\n${JSON.stringify(result, null, 2)}` }] };
113
+ });
114
+
115
+ server.tool('delete_record', 'Delete a record by ID', {
116
+ tableName: z.string().describe('Table name'),
117
+ id: z.string().describe('Record ID to delete'),
118
+ }, async ({ tableName, id }) => {
119
+ validateTableName(tableName);
120
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${id}`, { method: 'DELETE' });
121
+ return { content: [{ type: 'text', text: `Record deleted:\n${JSON.stringify(result, null, 2)}` }] };
122
+ });
123
+
124
+ // ============================================================================
125
+ // ROUTE & HANDLER TOOLS
126
+ // ============================================================================
127
+
128
+ server.tool('get_all_routes', 'Get all route definitions with handlers, hooks, and permissions', {
129
+ includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
130
+ }, async ({ includeDisabled }) => {
131
+ const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
132
+ const result = await fetchAPI(ENFYRA_API_URL, `/route_definition?filter=${encodeURIComponent(JSON.stringify(filter))}&limit=500`);
133
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
134
+ });
135
+
136
+ server.tool('create_route', 'Create a new route definition', {
137
+ path: z.string().describe('Route path (e.g., "/api/users")'),
138
+ method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'REST', 'GQL_QUERY', 'GQL_MUTATION']).describe('HTTP method'),
139
+ tableId: z.string().describe('Main table ID for this route'),
140
+ isEnabled: z.boolean().optional().default(true).describe('Enable route'),
141
+ description: z.string().optional().describe('Route description'),
142
+ }, async ({ path, method, tableId, isEnabled, description }) => {
143
+ const result = await fetchAPI(ENFYRA_API_URL, '/route_definition', {
144
+ method: 'POST',
145
+ body: JSON.stringify({ path, method, tableId, isEnabled, description }),
146
+ });
147
+ return { content: [{ type: 'text', text: `Route created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
148
+ });
149
+
150
+ server.tool('create_handler', 'Create a handler for a route. Use template syntax: @BODY, @USER, #table_name, @THROW404', {
151
+ routeId: z.string().describe('Route definition ID'),
152
+ logic: z.string().describe('Handler logic (JavaScript code)'),
153
+ timeout: z.number().optional().describe('Handler timeout in ms (default: 30000)'),
154
+ }, async ({ routeId, logic, timeout }) => {
155
+ const result = await fetchAPI(ENFYRA_API_URL, '/route_handler_definition', {
156
+ method: 'POST',
157
+ body: JSON.stringify({ routeId, logic, timeout: timeout || 30000 }),
158
+ });
159
+ return { content: [{ type: 'text', text: `Handler created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
160
+ });
161
+
162
+ server.tool('create_pre_hook', 'Create a pre-hook for a route. Use template syntax: @BODY, @QUERY, @USER', {
163
+ routeId: z.string().describe('Route definition ID'),
164
+ code: z.string().describe('Hook code (JavaScript)'),
165
+ methods: z.array(z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])).optional().describe('Methods this hook applies to'),
166
+ order: z.number().optional().default(0).describe('Hook execution order'),
167
+ }, async ({ routeId, code, methods, order }) => {
168
+ const result = await fetchAPI(ENFYRA_API_URL, '/pre_hook_definition', {
169
+ method: 'POST',
170
+ body: JSON.stringify({ routeId, code, methods: methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], order }),
171
+ });
172
+ return { content: [{ type: 'text', text: `Pre-hook created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
173
+ });
174
+
175
+ server.tool('create_post_hook', 'Create a post-hook for a route. Use template syntax: @DATA, @STATUS', {
176
+ routeId: z.string().describe('Route definition ID'),
177
+ code: z.string().describe('Hook code (JavaScript)'),
178
+ methods: z.array(z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])).optional().describe('Methods this hook applies to'),
179
+ order: z.number().optional().default(0).describe('Hook execution order'),
180
+ }, async ({ routeId, code, methods, order }) => {
181
+ const result = await fetchAPI(ENFYRA_API_URL, '/post_hook_definition', {
182
+ method: 'POST',
183
+ body: JSON.stringify({ routeId, code, methods: methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], order }),
184
+ });
185
+ return { content: [{ type: 'text', text: `Post-hook created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
186
+ });
187
+
188
+ // Register table tools
189
+ registerTableTools(server, ENFYRA_API_URL);
190
+
191
+ // ============================================================================
192
+ // CACHE & SYSTEM TOOLS
193
+ // ============================================================================
194
+
195
+ server.tool('reload_all', 'Reload all caches (metadata, routes, swagger, GraphQL)', {}, async () => {
196
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload', { method: 'POST' });
197
+ return { content: [{ type: 'text', text: `System reloaded:\n${JSON.stringify(result, null, 2)}` }] };
198
+ });
199
+
200
+ server.tool('reload_metadata', 'Reload metadata cache only', {}, async () => {
201
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' });
202
+ return { content: [{ type: 'text', text: `Metadata reloaded:\n${JSON.stringify(result, null, 2)}` }] };
203
+ });
204
+
205
+ server.tool('reload_routes', 'Reload routes cache only', {}, async () => {
206
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
207
+ return { content: [{ type: 'text', text: `Routes reloaded:\n${JSON.stringify(result, null, 2)}` }] };
208
+ });
209
+
210
+ server.tool('reload_swagger', 'Reload Swagger/OpenAPI spec', {}, async () => {
211
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/swagger', { method: 'POST' });
212
+ return { content: [{ type: 'text', text: `Swagger reloaded:\n${JSON.stringify(result, null, 2)}` }] };
213
+ });
214
+
215
+ server.tool('reload_graphql', 'Reload GraphQL schema', {}, async () => {
216
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/graphql', { method: 'POST' });
217
+ return { content: [{ type: 'text', text: `GraphQL reloaded:\n${JSON.stringify(result, null, 2)}` }] };
218
+ });
219
+
220
+ // ============================================================================
221
+ // LOGS TOOLS
222
+ // ============================================================================
223
+
224
+ server.tool('get_log_files', 'List available log files and stats', {}, async () => {
225
+ const result = await fetchAPI(ENFYRA_API_URL, '/logs');
226
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
227
+ });
228
+
229
+ server.tool('get_log_content', 'Get content of a specific log file', {
230
+ filename: z.string().describe('Log file name'),
231
+ page: z.number().optional().default(1).describe('Page number'),
232
+ pageSize: z.number().optional().default(100).describe('Lines per page'),
233
+ filter: z.string().optional().describe('Text filter'),
234
+ level: z.string().optional().describe('Log level filter (INFO, WARN, ERROR)'),
235
+ }, async ({ filename, page, pageSize, filter, level }) => {
236
+ const queryParams = new URLSearchParams();
237
+ if (page) queryParams.set('page', String(page));
238
+ if (pageSize) queryParams.set('pageSize', String(pageSize));
239
+ if (filter) queryParams.set('filter', filter);
240
+ if (level) queryParams.set('level', level);
241
+ const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`);
242
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
243
+ });
244
+
245
+ server.tool('tail_log', 'Get last N lines from a log file', {
246
+ filename: z.string().describe('Log file name'),
247
+ lines: z.number().optional().default(50).describe('Number of lines to retrieve'),
248
+ }, async ({ filename, lines }) => {
249
+ const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}/tail?lines=${lines}`);
250
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
251
+ });
252
+
253
+ server.tool('search_logs', 'Search for ERROR or WARN logs across recent log files', {
254
+ level: z.enum(['ERROR', 'WARN', 'INFO']).optional().default('ERROR').describe('Log level'),
255
+ keyword: z.string().optional().describe('Keyword to filter logs'),
256
+ limit: z.number().optional().default(50).describe('Max results per level'),
257
+ }, async ({ level, keyword, limit }) => {
258
+ const logFilesResult = await fetchAPI(ENFYRA_API_URL, '/logs');
259
+ const logFiles = logFilesResult.files || [];
260
+ const recentFiles = logFiles.filter(f => f.name.includes('app-') || f.name.includes('error-'));
261
+ const results = [];
262
+ for (const file of recentFiles.slice(0, 3)) {
263
+ try {
264
+ const contentResult = await fetchAPI(ENFYRA_API_URL, `/logs/${file.name}?level=${level}&pageSize=${limit}`);
265
+ const lines = contentResult.lines || contentResult.data || [];
266
+ const filteredLines = keyword ? lines.filter(l => JSON.stringify(l).toLowerCase().includes(keyword.toLowerCase())) : lines;
267
+ if (filteredLines.length > 0) results.push({ file: file.name, level, logs: filteredLines });
268
+ } catch (e) { /* skip */ }
269
+ }
270
+ return { content: [{ type: 'text', text: `Found ${results.length} files:\n${JSON.stringify(results, null, 2)}` }] };
271
+ });
272
+
273
+ // ============================================================================
274
+ // AUTH & USER TOOLS
275
+ // ============================================================================
276
+
277
+ server.tool('get_current_user', 'Get current authenticated user info', {}, async () => {
278
+ const result = await fetchAPI(ENFYRA_API_URL, '/me');
279
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
280
+ });
281
+
282
+ server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
283
+ const result = await fetchAPI(ENFYRA_API_URL, '/role_definition?limit=100');
284
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
285
+ });
286
+
287
+ server.tool('login', 'Force login to Enfyra and get new tokens', {
288
+ email: z.string().email().optional().describe('Admin email'),
289
+ password: z.string().optional().describe('Password'),
290
+ }, async ({ email, password }) => {
291
+ const loginEmail = email || ENFYRA_EMAIL;
292
+ const loginPassword = password || ENFYRA_PASSWORD;
293
+ if (!loginEmail || !loginPassword) throw new Error('Email and password required');
294
+ await login(ENFYRA_API_URL, loginEmail, loginPassword);
295
+ return { content: [{ type: 'text', text: `Logged in successfully!\nToken expires: ${new Date(getTokenExpiry()).toISOString()}` }] };
296
+ });
297
+
298
+ // ============================================================================
299
+ // MENU & EXTENSION TOOLS
300
+ // ============================================================================
301
+
302
+ server.tool('create_menu', 'Create a menu item in the navigation', {
303
+ label: z.string().describe('Menu label'),
304
+ type: z.enum(['separator', 'link', 'route', 'dropdown', 'widget', 'extension']).describe('Menu type'),
305
+ icon: z.string().optional().describe('Lucide icon name'),
306
+ path: z.string().optional().describe('Route path for type=route'),
307
+ externalUrl: z.string().optional().describe('External URL for type=link'),
308
+ order: z.number().optional().default(0).describe('Display order'),
309
+ isEnabled: z.boolean().optional().default(true).describe('Enable menu'),
310
+ description: z.string().optional().describe('Menu description'),
311
+ }, async (data) => {
312
+ const result = await fetchAPI(ENFYRA_API_URL, '/menu_definition', { method: 'POST', body: JSON.stringify(data) });
313
+ return { content: [{ type: 'text', text: `Menu created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
314
+ });
315
+
316
+ server.tool('create_extension', 'Create a code extension (custom UI page or widget)', {
317
+ name: z.string().describe('Extension name (unique)'),
318
+ type: z.enum(['page', 'widget']).describe('Extension type'),
319
+ code: z.string().describe('Component code as string (React/Vue)'),
320
+ routePath: z.string().optional().describe('Route path for page type'),
321
+ menuLabel: z.string().optional().describe('Menu label (auto-creates menu)'),
322
+ menuIcon: z.string().optional().describe('Menu icon'),
323
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension'),
324
+ description: z.string().optional().describe('Extension description'),
325
+ }, async (data) => {
326
+ const result = await fetchAPI(ENFYRA_API_URL, '/extension_definition', { method: 'POST', body: JSON.stringify(data) });
327
+ return { content: [{ type: 'text', text: `Extension created (ID: ${result.id}):\n${JSON.stringify(result, null, 2)}` }] };
328
+ });
329
+
330
+ // ============================================================================
331
+ // MAIN
332
+ // ============================================================================
333
+
334
+ async function main() {
335
+ console.error('Starting Enfyra MCP Server...');
336
+ console.error(`API URL: ${ENFYRA_API_URL}`);
337
+ console.error(`Auth: ${ENFYRA_EMAIL ? `Configured (${ENFYRA_EMAIL})` : 'Not configured'}`);
338
+
339
+ const transport = new StdioServerTransport();
340
+ await server.connect(transport);
341
+
342
+ console.error('Enfyra MCP Server running on stdio');
343
+ }
344
+
345
+ main().catch((error) => {
346
+ console.error('Fatal error:', error);
347
+ process.exit(1);
348
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Authentication module for Enfyra MCP Server
3
+ * Handles login, token refresh, and token validation
4
+ */
5
+
6
+ // Token state
7
+ let accessToken = null;
8
+ let refreshToken = null;
9
+ let tokenExpiry = null; // expTime từ server (milliseconds)
10
+ let isRefreshing = false;
11
+
12
+ // Config
13
+ let API_URL = 'http://localhost:3000/api';
14
+ let EMAIL = '';
15
+ let PASSWORD = '';
16
+
17
+ // Refresh buffer: refresh token 1 minute before expiry
18
+ const TOKEN_REFRESH_BUFFER = 60000;
19
+
20
+ /**
21
+ * Initialize auth module with config
22
+ */
23
+ export function initAuth(apiUrl, email, password) {
24
+ API_URL = apiUrl;
25
+ EMAIL = email;
26
+ PASSWORD = password;
27
+ }
28
+
29
+ /**
30
+ * Check if token needs refresh (expires within 1 minute)
31
+ */
32
+ export function needsRefresh() {
33
+ if (!tokenExpiry) return true;
34
+ const now = Date.now();
35
+ return now + TOKEN_REFRESH_BUFFER >= tokenExpiry;
36
+ }
37
+
38
+ /**
39
+ * Get current access token (does not refresh)
40
+ */
41
+ export function getAccessToken() {
42
+ return accessToken;
43
+ }
44
+
45
+ /**
46
+ * Get token expiry time
47
+ */
48
+ export function getTokenExpiry() {
49
+ return tokenExpiry;
50
+ }
51
+
52
+ /**
53
+ * Login and get access + refresh tokens
54
+ */
55
+ export async function login(url, email, password) {
56
+ const apiUrl = url || API_URL;
57
+ const authEmail = email || EMAIL;
58
+ const authPassword = password || PASSWORD;
59
+
60
+ if (!authEmail || !authPassword) {
61
+ throw new Error('Email and password required');
62
+ }
63
+
64
+ console.error('[Auth] Logging in...');
65
+ const response = await fetch(`${apiUrl}/auth/login`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ email: authEmail, password: authPassword }),
69
+ });
70
+
71
+ if (!response.ok) {
72
+ throw new Error(`Login failed: ${await response.text()}`);
73
+ }
74
+
75
+ const data = await response.json();
76
+ accessToken = data.accessToken || data.access_token;
77
+ refreshToken = data.refreshToken || data.refresh_token;
78
+ tokenExpiry = data.expTime;
79
+
80
+ console.error(`[Auth] Logged in as ${authEmail}, token expires at ${new Date(tokenExpiry).toISOString()}`);
81
+ return accessToken;
82
+ }
83
+
84
+ /**
85
+ * Refresh access token using refresh token
86
+ */
87
+ export async function refreshAccessToken(url, email, password) {
88
+ const apiUrl = url || API_URL;
89
+ const authEmail = email || EMAIL;
90
+ const authPassword = password || PASSWORD;
91
+
92
+ if (isRefreshing) {
93
+ await new Promise(resolve => setTimeout(resolve, 500));
94
+ return accessToken;
95
+ }
96
+
97
+ if (!refreshToken) {
98
+ console.error('[Auth] No refresh token, performing fresh login');
99
+ return await login(apiUrl, authEmail, authPassword);
100
+ }
101
+
102
+ isRefreshing = true;
103
+ try {
104
+ console.error('[Auth] Refreshing token...');
105
+ const response = await fetch(`${apiUrl}/auth/refresh-token`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ refreshToken }),
109
+ });
110
+
111
+ if (!response.ok) {
112
+ console.error('[Auth] Refresh failed, logging in fresh');
113
+ refreshToken = null;
114
+ return await login(apiUrl, authEmail, authPassword);
115
+ }
116
+
117
+ const data = await response.json();
118
+ accessToken = data.accessToken || data.access_token;
119
+ refreshToken = data.refreshToken || data.refresh_token;
120
+ tokenExpiry = data.expTime;
121
+
122
+ console.error(`[Auth] Token refreshed, expires at ${new Date(tokenExpiry).toISOString()}`);
123
+ return accessToken;
124
+ } finally {
125
+ isRefreshing = false;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Get valid access token, refreshing if needed
131
+ */
132
+ export async function getValidToken(url, email, password) {
133
+ const apiUrl = url || API_URL;
134
+ const authEmail = email || EMAIL;
135
+ const authPassword = password || PASSWORD;
136
+
137
+ if (!accessToken || needsRefresh()) {
138
+ if (refreshToken) {
139
+ return await refreshAccessToken(apiUrl, authEmail, authPassword);
140
+ }
141
+ return await login(apiUrl, authEmail, authPassword);
142
+ }
143
+ return accessToken;
144
+ }
145
+
146
+ /**
147
+ * Reset token state (for logout)
148
+ */
149
+ export function resetTokens() {
150
+ accessToken = null;
151
+ refreshToken = null;
152
+ tokenExpiry = null;
153
+ isRefreshing = false;
154
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * HTTP client module for Enfyra MCP Server
3
+ * Handles API requests with auth, timeout, and error handling
4
+ */
5
+
6
+ import { getValidToken } from './auth.js';
7
+
8
+ // Timeout configuration
9
+ const FETCH_TIMEOUT = 30000; // 30 seconds
10
+
11
+ /**
12
+ * Make HTTP request to Enfyra API
13
+ * @param {string} apiUrl - Base API URL
14
+ * @param {string} path - Request path
15
+ * @param {object} options - Fetch options
16
+ * @returns {Promise<any>} Response data
17
+ */
18
+ export async function fetchAPI(apiUrl, path, options = {}) {
19
+ const url = `${apiUrl}${path}`;
20
+ const token = await getValidToken();
21
+
22
+ const headersList = [
23
+ ['Content-Type', 'application/json'],
24
+ ['Authorization', `Bearer ${token}`],
25
+ ];
26
+
27
+ if (options.headers) {
28
+ const optHeaders = options.headers;
29
+ for (const key of Object.keys(optHeaders)) {
30
+ const existingIdx = headersList.findIndex(h => h[0] === key);
31
+ if (existingIdx >= 0) {
32
+ headersList[existingIdx] = [key, optHeaders[key]];
33
+ } else {
34
+ headersList.push([key, optHeaders[key]]);
35
+ }
36
+ }
37
+ }
38
+
39
+ const controller = new AbortController();
40
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
41
+
42
+ try {
43
+ const res = await fetch(url, {
44
+ ...options,
45
+ headers: headersList,
46
+ signal: controller.signal,
47
+ });
48
+ clearTimeout(timeoutId);
49
+
50
+ if (!res.ok) {
51
+ const error = await res.text().catch(() => res.statusText);
52
+ throw new Error(`API error (${res.status}): ${error}`);
53
+ }
54
+
55
+ return res.json();
56
+ } catch (error) {
57
+ clearTimeout(timeoutId);
58
+ if (error.name === 'AbortError') {
59
+ throw new Error(`Request timeout after ${FETCH_TIMEOUT}ms`);
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Validate filter JSON and check for injection patterns
67
+ * @param {string} filterStr - Filter JSON string
68
+ * @returns {object|null} Parsed filter object
69
+ */
70
+ export function validateFilter(filterStr) {
71
+ if (!filterStr) return null;
72
+ try {
73
+ const parsed = JSON.parse(filterStr);
74
+ // Check depth limit (max 10 levels)
75
+ function checkDepth(obj, depth = 0) {
76
+ if (depth > 10) {
77
+ throw new Error('Filter depth exceeds maximum of 10 levels');
78
+ }
79
+ if (obj && typeof obj === 'object') {
80
+ for (const key of Object.keys(obj)) {
81
+ checkDepth(obj[key], depth + 1);
82
+ }
83
+ }
84
+ }
85
+ checkDepth(parsed);
86
+ return parsed;
87
+ } catch (e) {
88
+ if (e.message.includes('depth')) throw e;
89
+ throw new Error(`Invalid filter JSON: ${e.message}`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Validate table name (alphanumeric, underscores only)
95
+ * @param {string} tableName - Table name to validate
96
+ * @returns {string} Validated table name
97
+ */
98
+ export function validateTableName(tableName) {
99
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
100
+ throw new Error(`Invalid table name: ${tableName}. Must start with letter/underscore and contain only alphanumeric characters and underscores.`);
101
+ }
102
+ return tableName;
103
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Table & Column tools for Enfyra MCP Server
3
+ */
4
+ import { z } from 'zod';
5
+ import { fetchAPI, validateTableName } from './fetch.js';
6
+
7
+ /**
8
+ * Register table tools with MCP server
9
+ */
10
+ export function registerTableTools(server, ENFYRA_API_URL) {
11
+ server.tool(
12
+ 'get_all_tables',
13
+ 'Get all table definitions in the system',
14
+ {},
15
+ async () => {
16
+ const result = await fetchAPI(ENFYRA_API_URL, '/table_definition?limit=500');
17
+ return {
18
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
19
+ };
20
+ }
21
+ );
22
+
23
+ server.tool(
24
+ 'create_table',
25
+ 'Create a new table definition. After creating a table, use create_column to add columns.',
26
+ {
27
+ name: z.string().describe('Table name (e.g., "user_definition", "my_custom_table"). Must be unique, lowercase with underscores.'),
28
+ alias: z.string().optional().describe('Table alias for API. If not provided, the table name will be used.'),
29
+ description: z.string().optional().describe('Description of what this table stores.'),
30
+ isEnabled: z.boolean().optional().default(true).describe('Enable table. Set to false to disable.'),
31
+ },
32
+ async ({ name, alias, description, isEnabled }) => {
33
+ const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
34
+ method: 'POST',
35
+ body: JSON.stringify({ name, alias, description, isEnabled }),
36
+ });
37
+ return {
38
+ content: [{ type: 'text', text: `Table created successfully with ID: ${result.id}. Next step: use create_column to add columns (tableId: ${result.id}).\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
39
+ };
40
+ }
41
+ );
42
+
43
+ server.tool(
44
+ 'create_column',
45
+ 'Create a column for an existing table. Columns cascade through table_definition.',
46
+ {
47
+ tableId: z.string().describe('Table definition ID (from get_all_tables or create_table).'),
48
+ name: z.string().describe('Column name (e.g., "title", "user_id"). Lowercase with underscores.'),
49
+ type: z.string().describe('Column type: varchar, int, text, boolean, datetime, json, decimal, etc.'),
50
+ length: z.number().optional().describe('Length for varchar types (e.g., 255).'),
51
+ isRequired: z.boolean().optional().default(false).describe('Set to true if column cannot be null.'),
52
+ isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
53
+ defaultValue: z.string().optional().describe('Default value as JSON string.'),
54
+ description: z.string().optional().describe('Column description.'),
55
+ },
56
+ async ({ tableId, name, type, length, isRequired, isUnique, defaultValue, description }) => {
57
+ const result = await fetchAPI(ENFYRA_API_URL, '/column_definition', {
58
+ method: 'POST',
59
+ body: JSON.stringify({ tableId, name, type, length, isRequired, isUnique, defaultValue, description }),
60
+ });
61
+ return {
62
+ content: [{ type: 'text', text: `Column created with ID: ${result.id}.\n\n${JSON.stringify(result, null, 2)}` }],
63
+ };
64
+ }
65
+ );
66
+ }