@enfyra/mcp-server 0.0.8 → 0.0.10

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.8",
3
+ "version": "0.0.10",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -351,6 +351,109 @@ server.tool('login', 'Force login to Enfyra and get new tokens', {
351
351
  return { content: [{ type: 'text', text: `Logged in successfully!\nToken expires: ${new Date(getTokenExpiry()).toISOString()}` }] };
352
352
  });
353
353
 
354
+ // ============================================================================
355
+ // PACKAGE TOOLS
356
+ // ============================================================================
357
+
358
+ server.tool(
359
+ 'search_npm',
360
+ 'Search NPM registry for packages. Returns name, version, description for installation.',
361
+ {
362
+ query: z.string().describe('Package name or search term (e.g., "axios", "node-ssh", "dayjs")'),
363
+ limit: z.number().optional().default(5).describe('Max results (default: 5)'),
364
+ },
365
+ async ({ query, limit }) => {
366
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`;
367
+ const response = await fetch(url);
368
+ if (!response.ok) throw new Error(`NPM search failed: ${response.statusText}`);
369
+ const data = await response.json();
370
+
371
+ const packages = data.objects.map((obj) => ({
372
+ name: obj.package.name,
373
+ version: obj.package.version,
374
+ description: obj.package.description || '',
375
+ }));
376
+
377
+ return {
378
+ content: [{
379
+ type: 'text',
380
+ text: JSON.stringify({ packages, total: data.total }, null, 2),
381
+ }],
382
+ };
383
+ },
384
+ );
385
+
386
+ server.tool(
387
+ 'install_package',
388
+ [
389
+ 'Install an NPM package on Enfyra. Searches NPM registry for exact version, then creates package_definition record.',
390
+ 'Enfyra handles the actual yarn add internally based on type.',
391
+ 'Type "Server" = available in handlers/hooks as $ctx.$pkgs.packageName.',
392
+ 'Type "App" = available in extensions via getPackages().',
393
+ ].join(' '),
394
+ {
395
+ name: z.string().describe('Exact NPM package name (e.g., "node-ssh", "axios")'),
396
+ type: z.enum(['Server', 'App']).default('Server').describe('Where to install: Server (handlers/hooks) or App (extensions)'),
397
+ version: z.string().optional().describe('Specific version. If omitted, fetches latest from NPM.'),
398
+ },
399
+ async ({ name, type, version }) => {
400
+ // Step 1: Get package info from NPM if version not specified
401
+ let pkgVersion = version;
402
+ let pkgDescription = '';
403
+
404
+ if (!pkgVersion) {
405
+ const npmUrl = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(name)}&size=5`;
406
+ const npmResponse = await fetch(npmUrl);
407
+ if (!npmResponse.ok) throw new Error(`NPM search failed: ${npmResponse.statusText}`);
408
+ const npmData = await npmResponse.json();
409
+
410
+ const exactMatch = npmData.objects.find((obj) => obj.package.name === name);
411
+ if (!exactMatch) throw new Error(`Package "${name}" not found on NPM`);
412
+
413
+ pkgVersion = exactMatch.package.version;
414
+ pkgDescription = exactMatch.package.description || '';
415
+ }
416
+
417
+ // Step 2: Check if already installed
418
+ const checkFilter = JSON.stringify({ name: { _eq: name } });
419
+ const existing = await fetchAPI(ENFYRA_API_URL, `/package_definition?filter=${encodeURIComponent(checkFilter)}&limit=1`);
420
+ if (existing.data && existing.data.length > 0) {
421
+ return {
422
+ content: [{
423
+ type: 'text',
424
+ text: `Package "${name}" is already installed (version: ${existing.data[0].version}, type: ${existing.data[0].type}).\n${JSON.stringify(existing.data[0], null, 2)}`,
425
+ }],
426
+ };
427
+ }
428
+
429
+ // Step 3: Get current user for installedBy
430
+ const me = await fetchAPI(ENFYRA_API_URL, '/me');
431
+ const userId = me.data?.[0]?.id || me.data?.[0]?._id;
432
+ if (!userId) throw new Error('Cannot get current user ID');
433
+
434
+ // Step 4: Install via package_definition
435
+ const body = {
436
+ name,
437
+ version: pkgVersion,
438
+ description: pkgDescription,
439
+ type,
440
+ installedBy: { id: userId },
441
+ };
442
+
443
+ const result = await fetchAPI(ENFYRA_API_URL, '/package_definition', {
444
+ method: 'POST',
445
+ body: JSON.stringify(body),
446
+ });
447
+
448
+ return {
449
+ content: [{
450
+ type: 'text',
451
+ text: `Package "${name}@${pkgVersion}" installed successfully (type: ${type}).\n${JSON.stringify(result, null, 2)}`,
452
+ }],
453
+ };
454
+ },
455
+ );
456
+
354
457
  // ============================================================================
355
458
  // MENU & EXTENSION TOOLS
356
459
  // ============================================================================
@@ -70,7 +70,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
70
70
  '- **`column_definition` has NO route** — do NOT call `query_table("column_definition", …)`. It will always 404.',
71
71
  '- To check which tables are accessible via MCP tools, call `get_all_routes` and look for the route whose `mainTable.id` matches the table you need, or `get_all_metadata` to see all table names.',
72
72
  '- **Tables confirmed to have REST routes (system):** `table_definition`, `route_definition`, `user_definition`, `setting_definition`, `ai_config_definition`, `role_definition`, `menu_definition`, `extension_definition`, `folder_definition`, `file_definition`, `file_permission_definition`, `package_definition`, `bootstrap_script_definition`, `storage_config_definition`, `ai_conversation_definition`, `ai_message_definition`, `websocket_definition`, `websocket_event_definition`, `oauth_config_definition`, `oauth_account_definition`, `method_definition`, `pre_hook_definition`, `post_hook_definition`, `route_handler_definition`, `route_permission_definition`.',
73
- '- **Tables without REST routes (internal/system only):** `column_definition`, `relation_definition` — these are managed indirectly via `create_column`, `create_table`, or admin sync endpoints.',
73
+ '- **Tables without REST routes (internal/system only):** `column_definition`, `relation_definition` — these are managed indirectly via cascade on `table_definition` (PATCH /table_definition/{id} with columns/relations array). The `create_column` MCP tool handles this automatically.',
74
74
  '',
75
75
  '### Schema / table migration (sequential only)',
76
76
  '- When creating, updating, or deleting tables (or columns), run operations **one at a time**. The migration process locks the DB per operation.',
@@ -156,9 +156,13 @@ export function buildMcpServerInstructions(apiBaseUrl) {
156
156
  '- **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`.',
157
157
  '- **type "widget":** Widget extension. No menu required. Embed via `<Widget :id="extensionId" />` in other extensions or pages.',
158
158
  '',
159
- '#### NPM packages:',
160
- '- Before using (e.g. dayjs, chart.js) check `package_definition` (filter `type: App`, `name`, `isEnabled`). If not found, tell user to install via Settings Packages.',
161
- '- Use `getPackages([\'dayjs\'])` in code; call in `onMounted` or async handler.',
159
+ '#### NPM packages (install via MCP):',
160
+ '- **Use `install_package` tool** just pass the package name and type. The tool auto-fetches version from NPM, checks if already installed, and creates the record.',
161
+ '- Example: `install_package({ name: "node-ssh", type: "Server" })` that is all. Tool handles everything.',
162
+ '- **Search first with `search_npm`** if unsure of exact package name.',
163
+ '- **Server** packages → available as `$ctx.$pkgs.packageName` in handlers/hooks.',
164
+ '- **App** packages → available via `getPackages([\'dayjs\'])` in extensions (call in `onMounted`).',
165
+ '- **Do NOT use `create_record` on `package_definition` directly** — use `install_package` instead.',
162
166
  '',
163
167
  '#### Important patterns:',
164
168
  '- **useApi:** Must call `execute()` — does NOT auto-run. Supports batch operations with `ids` or `files` options.',
@@ -4,11 +4,23 @@
4
4
  import { z } from 'zod';
5
5
  import { fetchAPI } from './fetch.js';
6
6
 
7
+ /**
8
+ * Helper: fetch table with columns and relations
9
+ */
10
+ async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
11
+ const filter = encodeURIComponent(JSON.stringify({ id: { _eq: tableId } }));
12
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition?filter=${filter}&limit=1&fields=*,columns.*,relations.*`);
13
+ return result?.data?.[0] || result?.[0] || null;
14
+ }
15
+
7
16
  /**
8
17
  * Register table tools with MCP server
9
18
  */
10
19
  export function registerTableTools(server, ENFYRA_API_URL) {
11
20
  const apiBase = ENFYRA_API_URL.replace(/\/$/, '');
21
+
22
+ // ─── READ ───
23
+
12
24
  server.tool(
13
25
  'get_all_tables',
14
26
  'Get all table definitions in the system',
@@ -21,11 +33,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
21
33
  }
22
34
  );
23
35
 
36
+ // ─── CREATE TABLE ───
37
+
24
38
  server.tool(
25
39
  'create_table',
26
40
  [
27
41
  'Create a new table definition with an auto-included `id` primary key column.',
28
- 'Use create_column to add more columns after creation.',
42
+ 'Use create_column to add more columns after creation (columns are managed via cascade PATCH on table_definition, NOT via /column_definition).',
29
43
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
30
44
  'Enfyra auto-creates a REST route at path `/<table_name>` (same segment as `name`, not alias).',
31
45
  '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).',
@@ -57,6 +71,194 @@ export function registerTableTools(server, ENFYRA_API_URL) {
57
71
  }
58
72
  );
59
73
 
74
+ // ─── UPDATE TABLE ───
75
+
76
+ server.tool(
77
+ 'update_table',
78
+ [
79
+ 'Update table properties: name (rename), alias, description, isSingleRecord, uniques, indexes.',
80
+ 'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
81
+ 'Run schema changes sequentially — migration locks DB per operation.',
82
+ ].join(' '),
83
+ {
84
+ tableId: z.string().describe('Table definition ID.'),
85
+ name: z.string().optional().describe('New table name (rename). Lowercase with underscores.'),
86
+ alias: z.string().optional().describe('New table alias.'),
87
+ description: z.string().optional().describe('New description.'),
88
+ isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
89
+ },
90
+ async ({ tableId, name, alias, description, isSingleRecord }) => {
91
+ const body = {};
92
+ if (name !== undefined) body.name = name;
93
+ if (alias !== undefined) body.alias = alias;
94
+ if (description !== undefined) body.description = description;
95
+ if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
96
+
97
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
98
+ method: 'PATCH',
99
+ body: JSON.stringify(body),
100
+ });
101
+ return {
102
+ content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
103
+ };
104
+ }
105
+ );
106
+
107
+ // ─── DELETE TABLE ───
108
+
109
+ server.tool(
110
+ 'delete_table',
111
+ [
112
+ 'Delete a table and ALL associated data. This is DESTRUCTIVE and IRREVERSIBLE.',
113
+ 'Deletes: table metadata, all columns, all relations (source + target), all routes, junction tables, FK columns from other tables, and the PHYSICAL DATABASE TABLE with ALL DATA.',
114
+ 'Always confirm with the user before calling this tool.',
115
+ ].join(' '),
116
+ {
117
+ tableId: z.string().describe('Table definition ID to delete.'),
118
+ },
119
+ async ({ tableId }) => {
120
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
121
+ method: 'DELETE',
122
+ });
123
+ return {
124
+ content: [{ type: 'text', text: `Table ${tableId} deleted.\n\n${JSON.stringify(result, null, 2)}` }],
125
+ };
126
+ }
127
+ );
128
+
129
+ // ─── CREATE COLUMN ───
130
+
131
+ server.tool(
132
+ 'create_column',
133
+ [
134
+ 'Add a column to an existing table via PATCH /table_definition/{tableId}.',
135
+ 'Columns are managed through cascade with table_definition — there is NO direct /column_definition endpoint.',
136
+ 'This tool fetches existing columns, appends the new one, and PATCHes the table.',
137
+ 'Run schema changes sequentially — migration locks DB per operation.',
138
+ ].join(' '),
139
+ {
140
+ tableId: z.string().describe('Table definition ID (from get_all_tables or create_table).'),
141
+ name: z.string().describe('Column name (e.g., "title", "user_id"). Lowercase with underscores.'),
142
+ type: z.string().describe('Column type: varchar, int, text, boolean, datetime, json, decimal, timestamp, uuid, bigint, float, longtext, richtext, simple-json, code, enum, array-select, date.'),
143
+ isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
144
+ isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
145
+ defaultValue: z.string().optional().describe('Default value as JSON string.'),
146
+ description: z.string().optional().describe('Column description.'),
147
+ options: z.string().optional().describe('Column options as JSON string (e.g., enum values).'),
148
+ },
149
+ async ({ tableId, name, type, isNullable, isUnique, defaultValue, description, options }) => {
150
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
151
+ if (!tableData) {
152
+ return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
153
+ }
154
+
155
+ const existingColumns = (tableData.columns || []).map(col => ({ id: col.id }));
156
+ const newCol = { name, type, isNullable: isNullable ?? true };
157
+ if (isUnique) newCol.isUnique = true;
158
+ if (defaultValue !== undefined) newCol.defaultValue = defaultValue;
159
+ if (description) newCol.description = description;
160
+ if (options) newCol.options = JSON.parse(options);
161
+
162
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
163
+ method: 'PATCH',
164
+ body: JSON.stringify({ columns: [...existingColumns, newCol] }),
165
+ });
166
+
167
+ return {
168
+ content: [{ type: 'text', text: `Column "${name}" added to table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
169
+ };
170
+ }
171
+ );
172
+
173
+ // ─── UPDATE COLUMN ───
174
+
175
+ server.tool(
176
+ 'update_column',
177
+ [
178
+ 'Update an existing column on a table via PATCH /table_definition/{tableId}.',
179
+ 'Fetches all columns, modifies the target column, and PATCHes the table.',
180
+ 'Run schema changes sequentially — migration locks DB per operation.',
181
+ ].join(' '),
182
+ {
183
+ tableId: z.string().describe('Table definition ID.'),
184
+ columnId: z.string().describe('Column definition ID to update.'),
185
+ name: z.string().optional().describe('New column name.'),
186
+ type: z.string().optional().describe('New column type.'),
187
+ isNullable: z.boolean().optional().describe('Set nullable.'),
188
+ isHidden: z.boolean().optional().describe('Hide column from API responses.'),
189
+ defaultValue: z.string().optional().describe('New default value as JSON string.'),
190
+ description: z.string().optional().describe('New description.'),
191
+ options: z.string().optional().describe('New options as JSON string.'),
192
+ },
193
+ async ({ tableId, columnId, name, type, isNullable, isHidden, defaultValue, description, options }) => {
194
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
195
+ if (!tableData) {
196
+ return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
197
+ }
198
+
199
+ const columns = (tableData.columns || []).map(col => {
200
+ if (String(col.id) === String(columnId)) {
201
+ const updated = { id: col.id };
202
+ if (name !== undefined) updated.name = name;
203
+ if (type !== undefined) updated.type = type;
204
+ if (isNullable !== undefined) updated.isNullable = isNullable;
205
+ if (isHidden !== undefined) updated.isHidden = isHidden;
206
+ if (defaultValue !== undefined) updated.defaultValue = defaultValue;
207
+ if (description !== undefined) updated.description = description;
208
+ if (options !== undefined) updated.options = JSON.parse(options);
209
+ return updated;
210
+ }
211
+ return { id: col.id };
212
+ });
213
+
214
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
215
+ method: 'PATCH',
216
+ body: JSON.stringify({ columns }),
217
+ });
218
+
219
+ return {
220
+ content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
221
+ };
222
+ }
223
+ );
224
+
225
+ // ─── DELETE COLUMN ───
226
+
227
+ server.tool(
228
+ 'delete_column',
229
+ [
230
+ 'Delete a column from a table via PATCH /table_definition/{tableId}.',
231
+ 'Fetches all columns, removes the target, and PATCHes the table.',
232
+ 'The physical column is dropped from the database. System columns (id, createdAt, updatedAt) cannot be deleted.',
233
+ 'Run schema changes sequentially — migration locks DB per operation.',
234
+ ].join(' '),
235
+ {
236
+ tableId: z.string().describe('Table definition ID.'),
237
+ columnId: z.string().describe('Column definition ID to delete.'),
238
+ },
239
+ async ({ tableId, columnId }) => {
240
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
241
+ if (!tableData) {
242
+ return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
243
+ }
244
+
245
+ const columns = (tableData.columns || [])
246
+ .filter(col => String(col.id) !== String(columnId))
247
+ .map(col => ({ id: col.id }));
248
+
249
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
250
+ method: 'PATCH',
251
+ body: JSON.stringify({ columns }),
252
+ });
253
+
254
+ return {
255
+ content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
256
+ };
257
+ }
258
+ );
259
+
260
+ // ─── CREATE RELATION ───
261
+
60
262
  server.tool(
61
263
  'create_relation',
62
264
  [
@@ -85,26 +287,36 @@ export function registerTableTools(server, ENFYRA_API_URL) {
85
287
  }
86
288
  );
87
289
 
290
+ // ─── DELETE RELATION ───
291
+
88
292
  server.tool(
89
- 'create_column',
90
- 'Create a column for an existing table. Columns cascade through table_definition. Run schema changes sequentially — migration locks DB per operation.',
293
+ 'delete_relation',
294
+ [
295
+ 'Delete a relation from a table via PATCH /table_definition/{tableId}.',
296
+ 'Fetches all relations, removes the target, and PATCHes the table.',
297
+ 'Drops FK columns and junction tables (for many-to-many).',
298
+ ].join(' '),
91
299
  {
92
- tableId: z.string().describe('Table definition ID (from get_all_tables or create_table).'),
93
- name: z.string().describe('Column name (e.g., "title", "user_id"). Lowercase with underscores.'),
94
- type: z.string().describe('Column type: varchar, int, text, boolean, datetime, json, decimal, etc.'),
95
- length: z.number().optional().describe('Length for varchar types (e.g., 255).'),
96
- isRequired: z.boolean().optional().default(false).describe('Set to true if column cannot be null.'),
97
- isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
98
- defaultValue: z.string().optional().describe('Default value as JSON string.'),
99
- description: z.string().optional().describe('Column description.'),
300
+ tableId: z.string().describe('Table definition ID (source table of the relation).'),
301
+ relationId: z.string().describe('Relation definition ID to delete.'),
100
302
  },
101
- async ({ tableId, name, type, length, isRequired, isUnique, defaultValue, description }) => {
102
- const result = await fetchAPI(ENFYRA_API_URL, '/column_definition', {
103
- method: 'POST',
104
- body: JSON.stringify({ tableId, name, type, length, isRequired, isUnique, defaultValue, description }),
303
+ async ({ tableId, relationId }) => {
304
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
305
+ if (!tableData) {
306
+ return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
307
+ }
308
+
309
+ const relations = (tableData.relations || [])
310
+ .filter(rel => String(rel.id) !== String(relationId))
311
+ .map(rel => ({ id: rel.id }));
312
+
313
+ const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
314
+ method: 'PATCH',
315
+ body: JSON.stringify({ relations }),
105
316
  });
317
+
106
318
  return {
107
- content: [{ type: 'text', text: `Column created with ID: ${result.id}.\n\n${JSON.stringify(result, null, 2)}` }],
319
+ content: [{ type: 'text', text: `Relation ${relationId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
108
320
  };
109
321
  }
110
322
  );