@enfyra/mcp-server 0.0.92 → 0.0.94

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.92",
3
+ "version": "0.0.94",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
24
24
  `GraphQL endpoints: \`${graphqlHttpUrl}\` and \`${graphqlSchemaUrl}\`.`,
25
25
  '',
26
26
  '### Work Flow',
27
+ '- For a quick target/base sanity check, call `get_enfyra_api_context`; do not call broad discovery just to confirm which instance this MCP is connected to.',
27
28
  '- Discover before deciding. For architecture/capability questions call `discover_enfyra_system`; for DB/pk/runtime/cache context call `discover_runtime_context`; for filters/deep/sort/relation query shape call `discover_query_capabilities`. Run broad discovery tools sequentially, not in parallel.',
28
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
29
30
  '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category.',
@@ -13,6 +13,7 @@ import { createHash } from 'node:crypto';
13
13
  // Configuration
14
14
  const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
15
15
  const ENFYRA_API_TOKEN = process.env.ENFYRA_API_TOKEN || '';
16
+ const DISCOVERY_FETCH_TIMEOUT_MS = 12000;
16
17
 
17
18
  // Import modules
18
19
  import { exchangeApiToken, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
@@ -360,6 +361,40 @@ function targetInstance() {
360
361
  };
361
362
  }
362
363
 
364
+ async function discoveryFetch(path, { fallbackData = [], timeoutMs = DISCOVERY_FETCH_TIMEOUT_MS } = {}) {
365
+ let timeoutId;
366
+ try {
367
+ const timeout = new Promise((_, reject) => {
368
+ timeoutId = setTimeout(() => {
369
+ reject(new Error(`Discovery request timeout after ${timeoutMs}ms for ${path}`));
370
+ }, timeoutMs);
371
+ });
372
+ return await Promise.race([
373
+ fetchAPI(ENFYRA_API_URL, path),
374
+ timeout,
375
+ ]);
376
+ } catch (error) {
377
+ return {
378
+ statusCode: null,
379
+ success: false,
380
+ error: String(error?.message || error),
381
+ data: fallbackData,
382
+ };
383
+ } finally {
384
+ if (timeoutId) clearTimeout(timeoutId);
385
+ }
386
+ }
387
+
388
+ function collectPartialErrors(results) {
389
+ return Object.entries(results)
390
+ .filter(([, result]) => result?.error)
391
+ .map(([name, result]) => ({ name, error: result.error }));
392
+ }
393
+
394
+ function jsonContent(payload, { pretty = false } = {}) {
395
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, pretty ? 2 : 0) }] };
396
+ }
397
+
363
398
  async function getMetadataTables() {
364
399
  const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
365
400
  return {
@@ -584,7 +619,7 @@ server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Us
584
619
  ...summarizeMetadata(result, { search, limit }),
585
620
  detailHint: 'Default response is capped and minimal. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for columns, relations, and route context.',
586
621
  };
587
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
622
+ return jsonContent(payload);
588
623
  });
589
624
 
590
625
  server.tool('get_table_metadata', 'Get concise metadata for a specific table by name', {
@@ -601,7 +636,7 @@ server.tool('get_table_metadata', 'Get concise metadata for a specific table by
601
636
  table: summarizeTable(table),
602
637
  queryHint: `Use query_table({ tableName: "${tableName}", fields: [...] }) for records. query_table without fields returns only the primary key.`,
603
638
  };
604
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
639
+ return jsonContent(payload);
605
640
  });
606
641
 
607
642
  server.tool(
@@ -615,7 +650,7 @@ server.tool(
615
650
  },
616
651
  async ({ category }) => {
617
652
  const result = getExamples(category);
618
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
653
+ return jsonContent(result);
619
654
  },
620
655
  );
621
656
 
@@ -624,15 +659,14 @@ server.tool(
624
659
  [
625
660
  'Call this first when you need to understand the live Enfyra instance.',
626
661
  'Returns a concise capability map from live metadata/routes/method rows, including schema management, REST route behavior, GraphQL enablement, and relation handling.',
662
+ 'Do not use this only to confirm the API base; use get_enfyra_api_context for that cheaper target check.',
627
663
  'Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
628
664
  ].join(' '),
629
665
  {},
630
666
  async () => {
631
- const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
632
- const [routesResult, methodsResult] = await Promise.all([
633
- fetchAPI(ENFYRA_API_URL, '/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*&limit=1000'),
634
- fetchAPI(ENFYRA_API_URL, '/enfyra_method?limit=100'),
635
- ]);
667
+ const metadata = await discoveryFetch('/metadata');
668
+ const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*&limit=1000');
669
+ const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
636
670
 
637
671
  const tables = normalizeTables(metadata);
638
672
  const tableNames = tables.map((table) => table?.name).filter(Boolean).sort();
@@ -643,10 +677,18 @@ server.tool(
643
677
  const tableDefinition = tables.find((table) => table?.name === 'enfyra_table');
644
678
  const gqlDefinition = tables.find((table) => table?.name === 'enfyra_graphql');
645
679
  const routeTableList = [...routeTables].sort();
680
+ const noRouteTableList = noRouteTables.sort();
681
+ const sample = (items, max = 40) => ({
682
+ total: items.length,
683
+ returned: Math.min(items.length, max),
684
+ items: items.slice(0, max),
685
+ truncated: items.length > max,
686
+ });
646
687
 
647
688
  const payload = {
648
689
  targetInstance: targetInstance(),
649
690
  apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
691
+ partialErrors: collectPartialErrors({ metadata, routesResult, methodsResult }),
650
692
  counts: {
651
693
  tables: tableNames.length,
652
694
  routes: routes.length,
@@ -662,10 +704,12 @@ server.tool(
662
704
  rest: {
663
705
  routePattern: 'Dynamic REST routes expose GET/POST at /<route-path> and PATCH/DELETE at /<route-path>/:id; there is no GET /<route-path>/:id.',
664
706
  publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
665
- routeTables: routeTableList,
666
- noRouteTables,
707
+ routeTables: sample(routeTableList),
708
+ noRouteTables: sample(noRouteTableList),
667
709
  canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
668
710
  customRouteWorkflow: 'For a new endpoint use create_route without mainTableId, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
711
+ routeSamples: sample(routes, 25),
712
+ detailHint: 'Use get_all_routes({ search, limit }) or inspect_route({ path }) for route details. Use inspect_table({ tableName }) for table detail.',
669
713
  },
670
714
  schemaManagement: {
671
715
  createTable: 'POST /enfyra_table supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly. It does not accept alias at create time; table name drives the default route/schema behavior.',
@@ -693,11 +737,10 @@ server.tool(
693
737
  : 'Use update_table graphqlEnabled, then reload_graphql if needed.',
694
738
  gqlDefinitionColumns: (gqlDefinition?.columns || []).map((column) => column.name),
695
739
  },
696
- tableNames,
697
- routes,
740
+ tableSamples: sample(tableNames, 40),
698
741
  };
699
742
 
700
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
743
+ return jsonContent(payload);
701
744
  },
702
745
  );
703
746
 
@@ -709,36 +752,42 @@ server.tool(
709
752
  ].join(' '),
710
753
  {},
711
754
  async () => {
712
- const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
713
- const [
714
- routesResult,
715
- methodsResult,
716
- gqlResult,
717
- flowsResult,
718
- websocketResult,
719
- storageResult,
720
- settingsResult,
721
- meResult,
722
- ] = await Promise.all([
723
- fetchAPI(ENFYRA_API_URL, '/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000'),
724
- fetchAPI(ENFYRA_API_URL, '/enfyra_method?limit=100'),
725
- fetchAPI(ENFYRA_API_URL, '/enfyra_graphql?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
726
- fetchAPI(ENFYRA_API_URL, '/enfyra_flow?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
727
- fetchAPI(ENFYRA_API_URL, '/enfyra_websocket?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
728
- fetchAPI(ENFYRA_API_URL, '/enfyra_storage_config?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
729
- fetchAPI(ENFYRA_API_URL, '/enfyra_setting?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
730
- fetchAPI(ENFYRA_API_URL, '/me').catch((error) => ({ error: String(error.message || error), data: [] })),
731
- ]);
755
+ const metadata = await discoveryFetch('/metadata');
756
+ const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
757
+ const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
758
+ const gqlResult = await discoveryFetch('/enfyra_graphql?limit=1000');
759
+ const flowsResult = await discoveryFetch('/enfyra_flow?limit=1000');
760
+ const websocketResult = await discoveryFetch('/enfyra_websocket?limit=1000');
761
+ const storageResult = await discoveryFetch('/enfyra_storage_config?limit=1000');
762
+ const settingsResult = await discoveryFetch('/enfyra_setting?limit=1000');
763
+ const meResult = await discoveryFetch('/me', { fallbackData: null });
732
764
 
733
765
  const tables = normalizeTables(metadata);
734
766
  const routes = summarizeRoutes(routesResult);
735
767
  const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
736
768
  const adminRoutes = routes.filter((route) => route.path?.startsWith('/admin'));
737
769
  const publicRoutes = routes.filter((route) => route.publicMethods?.length);
770
+ const sample = (items, max = 25) => ({
771
+ total: items.length,
772
+ returned: Math.min(items.length, max),
773
+ items: items.slice(0, max),
774
+ truncated: items.length > max,
775
+ });
738
776
 
739
777
  const payload = {
740
778
  targetInstance: targetInstance(),
741
779
  apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
780
+ partialErrors: collectPartialErrors({
781
+ metadata,
782
+ routesResult,
783
+ methodsResult,
784
+ gqlResult,
785
+ flowsResult,
786
+ websocketResult,
787
+ storageResult,
788
+ settingsResult,
789
+ meResult,
790
+ }),
742
791
  authenticatedUser: Array.isArray(meResult?.data) ? meResult.data[0] || null : meResult?.data || null,
743
792
  database: getMetadataDatabaseContext(metadata, tables),
744
793
  counts: {
@@ -759,12 +808,12 @@ server.tool(
759
808
  methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name })),
760
809
  routeRuntime: {
761
810
  routePattern: 'GET/POST /<route-path>; PATCH/DELETE /<route-path>/:id; no dynamic GET /<route-path>/:id.',
762
- adminRoutes: adminRoutes.map((route) => route.path).sort(),
763
- publicRoutes: publicRoutes.map((route) => ({
811
+ adminRoutes: sample(adminRoutes.map((route) => route.path).sort()),
812
+ publicRoutes: sample(publicRoutes.map((route) => ({
764
813
  path: route.path,
765
814
  mainTable: route.mainTable,
766
815
  publicMethods: route.publicMethods,
767
- })),
816
+ }))),
768
817
  },
769
818
  cacheAndCluster: {
770
819
  metadataMutationReloads: 'Metadata-backed mutations emit cache invalidation; admin reload endpoints exist for metadata/routes/graphql/guards/all.',
@@ -781,7 +830,7 @@ server.tool(
781
830
  'MCP can test flow steps and websocket scripts through admin test endpoints, but not prove every production queue/client path without a real end-to-end client.',
782
831
  ].filter(Boolean),
783
832
  };
784
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
833
+ return jsonContent(payload);
785
834
  },
786
835
  );
787
836
 
@@ -795,8 +844,8 @@ server.tool(
795
844
  tableName: z.string().optional().describe('Optional table name to summarize query fields and relation/deep capabilities.'),
796
845
  },
797
846
  async ({ tableName }) => {
798
- const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
799
- const routesResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
847
+ const metadata = await discoveryFetch('/metadata');
848
+ const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
800
849
  const tables = normalizeTables(metadata);
801
850
  const routes = summarizeRoutes(routesResult);
802
851
  const table = tableName ? tables.find((item) => item.name === tableName) : null;
@@ -807,6 +856,7 @@ server.tool(
807
856
 
808
857
  const payload = {
809
858
  targetInstance: targetInstance(),
859
+ partialErrors: collectPartialErrors({ metadata, routesResult }),
810
860
  operators: {
811
861
  filter: FILTER_OPERATORS,
812
862
  fieldPermissionConditions: FIELD_PERMISSION_CONDITION_OPERATORS,
@@ -862,7 +912,7 @@ server.tool(
862
912
  discoveryRule: 'When building a query, inspect table metadata first, then use relation propertyName and primary column from that metadata.',
863
913
  };
864
914
 
865
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
915
+ return jsonContent(payload);
866
916
  },
867
917
  );
868
918
 
@@ -1002,7 +1052,7 @@ server.tool(
1002
1052
  },
1003
1053
  };
1004
1054
 
1005
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1055
+ return jsonContent(payload);
1006
1056
  },
1007
1057
  );
1008
1058
 
@@ -1014,6 +1064,7 @@ server.tool(
1014
1064
  'get_enfyra_api_context',
1015
1065
  [
1016
1066
  'Returns the resolved API base URL for this MCP session (env ENFYRA_API_URL).',
1067
+ 'Use this as the cheap first target sanity check before broad discovery or mutations.',
1017
1068
  '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}).',
1018
1069
  'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
1019
1070
  'If path might differ from table name, use get_all_routes before asserting a URL.',
@@ -1025,6 +1076,7 @@ server.tool(
1025
1076
  const base = ENFYRA_API_URL.replace(/\/$/, '');
1026
1077
  const gql = buildGraphqlUrls(ENFYRA_API_URL);
1027
1078
  const payload = {
1079
+ targetInstance: targetInstance(),
1028
1080
  enfyraApiUrl: base,
1029
1081
  graphqlHttpUrl: gql.graphqlHttpUrl,
1030
1082
  graphqlSchemaUrl: gql.graphqlSchemaUrl,
@@ -1041,7 +1093,7 @@ server.tool(
1041
1093
  pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
1042
1094
  note: 'Full tool→HTTP mapping is in MCP server instructions (shown to the model at connect).',
1043
1095
  };
1044
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1096
+ return jsonContent(payload);
1045
1097
  },
1046
1098
  );
1047
1099
 
@@ -1703,6 +1755,51 @@ async function collectRestDefinitionState() {
1703
1755
  };
1704
1756
  }
1705
1757
 
1758
+ async function collectFeatureSearchState() {
1759
+ const metadata = await discoveryFetch('/metadata');
1760
+ const routesResult = await discoveryFetch('/enfyra_route?limit=500');
1761
+ const handlersResult = await discoveryFetch('/enfyra_route_handler?limit=500');
1762
+ const preHooksResult = await discoveryFetch('/enfyra_pre_hook?limit=500');
1763
+ const postHooksResult = await discoveryFetch('/enfyra_post_hook?limit=500');
1764
+ const routePermissionsResult = await discoveryFetch('/enfyra_route_permission?limit=500');
1765
+ const guardsResult = await discoveryFetch('/enfyra_guard?limit=500');
1766
+ const guardRulesResult = await discoveryFetch('/enfyra_guard_rule?limit=500');
1767
+ const fieldPermissionsResult = await discoveryFetch('/enfyra_field_permission?limit=500');
1768
+ const columnRulesResult = await discoveryFetch('/enfyra_column_rule?limit=500');
1769
+ const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
1770
+ const methodIdNameMap = Object.fromEntries(
1771
+ unwrapData(methodsResult).map((method) => [String(getId(method)), method.name]),
1772
+ );
1773
+
1774
+ return {
1775
+ metadata,
1776
+ tables: normalizeTables(metadata),
1777
+ routes: unwrapData(routesResult),
1778
+ handlers: unwrapData(handlersResult),
1779
+ preHooks: unwrapData(preHooksResult),
1780
+ postHooks: unwrapData(postHooksResult),
1781
+ routePermissions: unwrapData(routePermissionsResult),
1782
+ guards: unwrapData(guardsResult),
1783
+ guardRules: unwrapData(guardRulesResult),
1784
+ fieldPermissions: unwrapData(fieldPermissionsResult),
1785
+ columnRules: unwrapData(columnRulesResult),
1786
+ methodIdNameMap,
1787
+ partialErrors: collectPartialErrors({
1788
+ metadata,
1789
+ routesResult,
1790
+ handlersResult,
1791
+ preHooksResult,
1792
+ postHooksResult,
1793
+ routePermissionsResult,
1794
+ guardsResult,
1795
+ guardRulesResult,
1796
+ fieldPermissionsResult,
1797
+ columnRulesResult,
1798
+ methodsResult,
1799
+ }),
1800
+ };
1801
+ }
1802
+
1706
1803
  function enrichRoute(route, state) {
1707
1804
  const routeId = getId(route);
1708
1805
  const routeHandlers = state.handlers
@@ -1868,7 +1965,7 @@ server.tool(
1868
1965
  throw new Error('inspect_feature query must be at least 2 characters. Use a table name, route path, event name, or specific feature keyword.');
1869
1966
  }
1870
1967
  const max = Math.max(1, Math.min(Number(limit || 8), 25));
1871
- const state = await collectRestDefinitionState();
1968
+ const state = await collectFeatureSearchState();
1872
1969
  const q = rawQuery.toLowerCase();
1873
1970
  const matchesText = (value) => JSON.stringify(value ?? '').toLowerCase().includes(q);
1874
1971
  const tableMatches = state.tables.filter((table) => matchesText({
@@ -1892,6 +1989,7 @@ server.tool(
1892
1989
  targetInstance: targetInstance(),
1893
1990
  query: rawQuery,
1894
1991
  limit: max,
1992
+ partialErrors: state.partialErrors,
1895
1993
  counts: {
1896
1994
  tables: tableMatches.length,
1897
1995
  routes: routeMatches.length,