@enfyra/mcp-server 0.1.69 → 0.1.71

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.
Files changed (248) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/config-local-adapters.d.ts +18 -0
  3. package/dist/lib/config-local-adapters.js +271 -0
  4. package/dist/lib/config-local-adapters.js.map +1 -0
  5. package/dist/lib/config-local-contracts.d.ts +28 -0
  6. package/dist/lib/config-local-contracts.js +200 -0
  7. package/dist/lib/config-local-contracts.js.map +1 -0
  8. package/dist/lib/config-local-prompts.d.ts +10 -0
  9. package/dist/lib/config-local-prompts.js +185 -0
  10. package/dist/lib/config-local-prompts.js.map +1 -0
  11. package/dist/lib/config-local-types.d.ts +23 -0
  12. package/dist/lib/config-local-types.js +2 -0
  13. package/dist/lib/config-local-types.js.map +1 -0
  14. package/dist/lib/config-local.js +3 -649
  15. package/dist/lib/config-local.js.map +1 -1
  16. package/dist/lib/discovery-tools.d.ts +4 -0
  17. package/dist/lib/discovery-tools.js +546 -0
  18. package/dist/lib/discovery-tools.js.map +1 -0
  19. package/dist/lib/dynamic-repository-builder.d.ts +1 -1
  20. package/dist/lib/dynamic-script-context-contract.d.ts +100 -0
  21. package/dist/lib/dynamic-script-context-contract.js +163 -0
  22. package/dist/lib/dynamic-script-context-contract.js.map +1 -0
  23. package/dist/lib/enfyra-mcp-server.d.ts +3 -0
  24. package/dist/lib/enfyra-mcp-server.js +79 -0
  25. package/dist/lib/enfyra-mcp-server.js.map +1 -0
  26. package/dist/lib/enfyra-tool-logic.d.ts +7 -0
  27. package/dist/lib/enfyra-tool-logic.js +8 -0
  28. package/dist/lib/enfyra-tool-logic.js.map +1 -0
  29. package/dist/lib/enfyra-tool-types.d.ts +27 -0
  30. package/dist/lib/enfyra-tool-types.js +2 -0
  31. package/dist/lib/enfyra-tool-types.js.map +1 -0
  32. package/dist/lib/extension-api-builders.d.ts +17 -0
  33. package/dist/lib/extension-api-builders.js +235 -0
  34. package/dist/lib/extension-api-builders.js.map +1 -0
  35. package/dist/lib/extension-component-builders.d.ts +87 -0
  36. package/dist/lib/extension-component-builders.js +623 -0
  37. package/dist/lib/extension-component-builders.js.map +1 -0
  38. package/dist/lib/extension-contract-review.d.ts +26 -0
  39. package/dist/lib/extension-contract-review.js +187 -0
  40. package/dist/lib/extension-contract-review.js.map +1 -0
  41. package/dist/lib/extension-theme-contract.d.ts +63 -0
  42. package/dist/lib/extension-theme-contract.js +417 -0
  43. package/dist/lib/extension-theme-contract.js.map +1 -0
  44. package/dist/lib/identity-tools.d.ts +4 -0
  45. package/dist/lib/identity-tools.js +46 -0
  46. package/dist/lib/identity-tools.js.map +1 -0
  47. package/dist/lib/log-tools.d.ts +4 -0
  48. package/dist/lib/log-tools.js +65 -0
  49. package/dist/lib/log-tools.js.map +1 -0
  50. package/dist/lib/mcp-example-connect.d.ts +9 -0
  51. package/dist/lib/mcp-example-connect.js +442 -0
  52. package/dist/lib/mcp-example-connect.js.map +1 -0
  53. package/dist/lib/mcp-example-extensions.d.ts +9 -0
  54. package/dist/lib/mcp-example-extensions.js +729 -0
  55. package/dist/lib/mcp-example-extensions.js.map +1 -0
  56. package/dist/lib/mcp-example-files.d.ts +9 -0
  57. package/dist/lib/mcp-example-files.js +51 -0
  58. package/dist/lib/mcp-example-files.js.map +1 -0
  59. package/dist/lib/mcp-example-flows.d.ts +9 -0
  60. package/dist/lib/mcp-example-flows.js +76 -0
  61. package/dist/lib/mcp-example-flows.js.map +1 -0
  62. package/dist/lib/mcp-example-graphql.d.ts +9 -0
  63. package/dist/lib/mcp-example-graphql.js +57 -0
  64. package/dist/lib/mcp-example-graphql.js.map +1 -0
  65. package/dist/lib/mcp-example-handlers-hooks.d.ts +9 -0
  66. package/dist/lib/mcp-example-handlers-hooks.js +328 -0
  67. package/dist/lib/mcp-example-handlers-hooks.js.map +1 -0
  68. package/dist/lib/mcp-example-oauth-setup.d.ts +9 -0
  69. package/dist/lib/mcp-example-oauth-setup.js +41 -0
  70. package/dist/lib/mcp-example-oauth-setup.js.map +1 -0
  71. package/dist/lib/mcp-example-permissions-rls.d.ts +9 -0
  72. package/dist/lib/mcp-example-permissions-rls.js +210 -0
  73. package/dist/lib/mcp-example-permissions-rls.js.map +1 -0
  74. package/dist/lib/mcp-example-queries-deep.d.ts +9 -0
  75. package/dist/lib/mcp-example-queries-deep.js +220 -0
  76. package/dist/lib/mcp-example-queries-deep.js.map +1 -0
  77. package/dist/lib/mcp-example-schema-relations.d.ts +9 -0
  78. package/dist/lib/mcp-example-schema-relations.js +148 -0
  79. package/dist/lib/mcp-example-schema-relations.js.map +1 -0
  80. package/dist/lib/mcp-example-websocket.d.ts +9 -0
  81. package/dist/lib/mcp-example-websocket.js +103 -0
  82. package/dist/lib/mcp-example-websocket.js.map +1 -0
  83. package/dist/lib/mcp-examples.d.ts +5 -5
  84. package/dist/lib/mcp-examples.js +22 -2394
  85. package/dist/lib/mcp-examples.js.map +1 -1
  86. package/dist/lib/method-tools.d.ts +4 -0
  87. package/dist/lib/method-tools.js +217 -0
  88. package/dist/lib/method-tools.js.map +1 -0
  89. package/dist/lib/package-tools.d.ts +4 -0
  90. package/dist/lib/package-tools.js +98 -0
  91. package/dist/lib/package-tools.js.map +1 -0
  92. package/dist/lib/platform-data-operations.d.ts +24 -0
  93. package/dist/lib/platform-data-operations.js +71 -0
  94. package/dist/lib/platform-data-operations.js.map +1 -0
  95. package/dist/lib/platform-endpoint-workflow.d.ts +42 -0
  96. package/dist/lib/platform-endpoint-workflow.js +382 -0
  97. package/dist/lib/platform-endpoint-workflow.js.map +1 -0
  98. package/dist/lib/platform-extension-source.d.ts +487 -0
  99. package/dist/lib/platform-extension-source.js +524 -0
  100. package/dist/lib/platform-extension-source.js.map +1 -0
  101. package/dist/lib/platform-extension-tools.d.ts +1 -0
  102. package/dist/lib/platform-extension-tools.js +416 -0
  103. package/dist/lib/platform-extension-tools.js.map +1 -0
  104. package/dist/lib/platform-extension-ui.d.ts +4 -0
  105. package/dist/lib/platform-extension-ui.js +5 -0
  106. package/dist/lib/platform-extension-ui.js.map +1 -0
  107. package/dist/lib/platform-extension-workflow.d.ts +54 -0
  108. package/dist/lib/platform-extension-workflow.js +194 -0
  109. package/dist/lib/platform-extension-workflow.js.map +1 -0
  110. package/dist/lib/platform-flow-operations.d.ts +554 -0
  111. package/dist/lib/platform-flow-operations.js +317 -0
  112. package/dist/lib/platform-flow-operations.js.map +1 -0
  113. package/dist/lib/platform-flow-tools.d.ts +1 -0
  114. package/dist/lib/platform-flow-tools.js +248 -0
  115. package/dist/lib/platform-flow-tools.js.map +1 -0
  116. package/dist/lib/platform-operation-logic.d.ts +10 -0
  117. package/dist/lib/platform-operation-logic.js +11 -0
  118. package/dist/lib/platform-operation-logic.js.map +1 -0
  119. package/dist/lib/platform-operation-tools.d.ts +1 -324
  120. package/dist/lib/platform-operation-tools.js +13 -4620
  121. package/dist/lib/platform-operation-tools.js.map +1 -1
  122. package/dist/lib/platform-operation-types.d.ts +46 -0
  123. package/dist/lib/platform-operation-types.js +2 -0
  124. package/dist/lib/platform-operation-types.js.map +1 -0
  125. package/dist/lib/platform-policy-tools.d.ts +1 -0
  126. package/dist/lib/platform-policy-tools.js +248 -0
  127. package/dist/lib/platform-policy-tools.js.map +1 -0
  128. package/dist/lib/platform-resource-operations.d.ts +124 -0
  129. package/dist/lib/platform-resource-operations.js +109 -0
  130. package/dist/lib/platform-resource-operations.js.map +1 -0
  131. package/dist/lib/platform-resource-tools.d.ts +1 -0
  132. package/dist/lib/platform-resource-tools.js +70 -0
  133. package/dist/lib/platform-resource-tools.js.map +1 -0
  134. package/dist/lib/platform-route-operations.d.ts +222 -0
  135. package/dist/lib/platform-route-operations.js +290 -0
  136. package/dist/lib/platform-route-operations.js.map +1 -0
  137. package/dist/lib/platform-route-tools.d.ts +1 -0
  138. package/dist/lib/platform-route-tools.js +307 -0
  139. package/dist/lib/platform-route-tools.js.map +1 -0
  140. package/dist/lib/platform-shared-operations.d.ts +7 -0
  141. package/dist/lib/platform-shared-operations.js +4 -0
  142. package/dist/lib/platform-shared-operations.js.map +1 -0
  143. package/dist/lib/platform-websocket-tools.d.ts +1 -0
  144. package/dist/lib/platform-websocket-tools.js +70 -0
  145. package/dist/lib/platform-websocket-tools.js.map +1 -0
  146. package/dist/lib/record-tools.d.ts +4 -0
  147. package/dist/lib/record-tools.js +418 -0
  148. package/dist/lib/record-tools.js.map +1 -0
  149. package/dist/lib/required-knowledge.d.ts +1 -1
  150. package/dist/lib/required-knowledge.js +3 -2
  151. package/dist/lib/required-knowledge.js.map +1 -1
  152. package/dist/lib/route-access-tools.d.ts +4 -0
  153. package/dist/lib/route-access-tools.js +172 -0
  154. package/dist/lib/route-access-tools.js.map +1 -0
  155. package/dist/lib/route-definition-tools.d.ts +4 -0
  156. package/dist/lib/route-definition-tools.js +285 -0
  157. package/dist/lib/route-definition-tools.js.map +1 -0
  158. package/dist/lib/route-inspection-tools.d.ts +4 -0
  159. package/dist/lib/route-inspection-tools.js +319 -0
  160. package/dist/lib/route-inspection-tools.js.map +1 -0
  161. package/dist/lib/route-tools.d.ts +1 -0
  162. package/dist/lib/route-tools.js +9 -0
  163. package/dist/lib/route-tools.js.map +1 -0
  164. package/dist/lib/runtime-zone-registry.d.ts +27 -0
  165. package/dist/lib/runtime-zone-registry.js +81 -0
  166. package/dist/lib/runtime-zone-registry.js.map +1 -0
  167. package/dist/lib/runtime-zone-search.d.ts +303 -0
  168. package/dist/lib/runtime-zone-search.js +596 -0
  169. package/dist/lib/runtime-zone-search.js.map +1 -0
  170. package/dist/lib/runtime-zone-tools.d.ts +2 -307
  171. package/dist/lib/runtime-zone-tools.js +2 -674
  172. package/dist/lib/runtime-zone-tools.js.map +1 -1
  173. package/dist/lib/schema-column-tools.d.ts +3 -0
  174. package/dist/lib/schema-column-tools.js +80 -0
  175. package/dist/lib/schema-column-tools.js.map +1 -0
  176. package/dist/lib/schema-mutation-coordinator.d.ts +28 -0
  177. package/dist/lib/schema-mutation-coordinator.js +154 -0
  178. package/dist/lib/schema-mutation-coordinator.js.map +1 -0
  179. package/dist/lib/schema-relation-contracts.d.ts +39 -0
  180. package/dist/lib/schema-relation-contracts.js +160 -0
  181. package/dist/lib/schema-relation-contracts.js.map +1 -0
  182. package/dist/lib/schema-relation-tools.d.ts +3 -0
  183. package/dist/lib/schema-relation-tools.js +61 -0
  184. package/dist/lib/schema-relation-tools.js.map +1 -0
  185. package/dist/lib/schema-table-contracts.d.ts +28 -0
  186. package/dist/lib/schema-table-contracts.js +263 -0
  187. package/dist/lib/schema-table-contracts.js.map +1 -0
  188. package/dist/lib/schema-table-tools.d.ts +3 -0
  189. package/dist/lib/schema-table-tools.js +297 -0
  190. package/dist/lib/schema-table-tools.js.map +1 -0
  191. package/dist/lib/schema-tool-operations.d.ts +150 -0
  192. package/dist/lib/schema-tool-operations.js +468 -0
  193. package/dist/lib/schema-tool-operations.js.map +1 -0
  194. package/dist/lib/schema-tool-types.d.ts +38 -0
  195. package/dist/lib/schema-tool-types.js +2 -0
  196. package/dist/lib/schema-tool-types.js.map +1 -0
  197. package/dist/lib/schema-verification.d.ts +14 -0
  198. package/dist/lib/schema-verification.js +105 -0
  199. package/dist/lib/schema-verification.js.map +1 -0
  200. package/dist/lib/script-tools.d.ts +4 -0
  201. package/dist/lib/script-tools.js +127 -0
  202. package/dist/lib/script-tools.js.map +1 -0
  203. package/dist/lib/system-tools.d.ts +4 -0
  204. package/dist/lib/system-tools.js +41 -0
  205. package/dist/lib/system-tools.js.map +1 -0
  206. package/dist/lib/table-tool-logic.d.ts +5 -0
  207. package/dist/lib/table-tool-logic.js +6 -0
  208. package/dist/lib/table-tool-logic.js.map +1 -0
  209. package/dist/lib/table-tools.d.ts +1 -84
  210. package/dist/lib/table-tools.js +7 -1522
  211. package/dist/lib/table-tools.js.map +1 -1
  212. package/dist/lib/tool-catalog.d.ts +2 -1
  213. package/dist/lib/tool-catalog.js +23 -2
  214. package/dist/lib/tool-catalog.js.map +1 -1
  215. package/dist/lib/tool-metadata-operations.d.ts +27 -0
  216. package/dist/lib/tool-metadata-operations.js +93 -0
  217. package/dist/lib/tool-metadata-operations.js.map +1 -0
  218. package/dist/lib/tool-permission-profile.d.ts +37 -0
  219. package/dist/lib/tool-permission-profile.js +106 -0
  220. package/dist/lib/tool-permission-profile.js.map +1 -0
  221. package/dist/lib/tool-record-operations.d.ts +102 -0
  222. package/dist/lib/tool-record-operations.js +260 -0
  223. package/dist/lib/tool-record-operations.js.map +1 -0
  224. package/dist/lib/tool-route-inspection.d.ts +47 -0
  225. package/dist/lib/tool-route-inspection.js +168 -0
  226. package/dist/lib/tool-route-inspection.js.map +1 -0
  227. package/dist/lib/tool-routing.d.ts +3 -417
  228. package/dist/lib/tool-routing.js +3 -1038
  229. package/dist/lib/tool-routing.js.map +1 -1
  230. package/dist/lib/tool-runtime-config.d.ts +33 -0
  231. package/dist/lib/tool-runtime-config.js +237 -0
  232. package/dist/lib/tool-runtime-config.js.map +1 -0
  233. package/dist/lib/tool-script-operations.d.ts +34 -0
  234. package/dist/lib/tool-script-operations.js +153 -0
  235. package/dist/lib/tool-script-operations.js.map +1 -0
  236. package/dist/lib/workflow-definitions.d.ts +366 -0
  237. package/dist/lib/workflow-definitions.js +703 -0
  238. package/dist/lib/workflow-definitions.js.map +1 -0
  239. package/dist/lib/workflow-planner.d.ts +45 -0
  240. package/dist/lib/workflow-planner.js +338 -0
  241. package/dist/lib/workflow-planner.js.map +1 -0
  242. package/dist/lib/workflow-types.d.ts +39 -0
  243. package/dist/lib/workflow-types.js +2 -0
  244. package/dist/lib/workflow-types.js.map +1 -0
  245. package/dist/mcp-server-entry.d.ts +0 -3
  246. package/dist/mcp-server-entry.js +2 -3249
  247. package/dist/mcp-server-entry.js.map +1 -1
  248. package/package.json +1 -1
@@ -1,3252 +1,5 @@
1
- /**
2
- * Enfyra MCP — stdio server (loaded by index.ts / dist/index.js).
3
- */
4
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
5
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
- import { z } from 'zod';
7
- import { createHash } from 'node:crypto';
8
- // Configuration
9
- const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
10
- const ENFYRA_API_TOKEN = process.env.ENFYRA_API_TOKEN || '';
11
- const DISCOVERY_FETCH_TIMEOUT_MS = 12000;
12
- function asNonEmptyStringTuple(values, label) {
13
- if (!values.length) {
14
- throw new Error(`${label} must include at least one value.`);
15
- }
16
- return values;
17
- }
18
- function bulkObjectArrayParam(z, label) {
19
- return z.array(z.record(z.any())).describe(`${label} as a native JSON array of objects. Pass one object in the array for a single mutation.`);
20
- }
21
- function jsonObjectParam(z, label) {
22
- return z.record(z.any()).describe(`${label} as a native JSON object. Do not JSON.stringify this value.`);
23
- }
24
- function parseJsonObjectInput(value, label) {
25
- const parsed = typeof value === 'string' ? JSON.parse(value) : value;
26
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
27
- throw new Error(`${label} must be a JSON object.`);
28
- }
29
- return parsed;
30
- }
31
- function normalizeSortParam(sort) {
32
- if (!sort)
33
- return sort;
34
- return sort
35
- .split(',')
36
- .map((item) => item.trim().replace(/^(['"])(.*)\1$/u, '$2'))
37
- .filter(Boolean)
38
- .join(',');
39
- }
40
- function normalizeFieldSelection(fields) {
41
- return (fields || [])
42
- .flatMap((field) => String(field).split(','))
43
- .map((field) => field.trim())
44
- .filter(Boolean);
45
- }
46
- function assertExtensionReadFields(tableName, fields) {
47
- if (tableName !== 'enfyra_extension')
48
- return;
49
- const requestedFields = normalizeFieldSelection(fields);
50
- const requestsSourceCode = requestedFields.some((field) => field === 'sourceCode' || field.endsWith('.sourceCode'));
51
- if (!requestsSourceCode)
52
- return;
53
- throw new Error([
54
- 'enfyra_extension stores editable Vue SFC extension source in `code`, not `sourceCode`.',
55
- '`sourceCode` belongs to dynamic server script records such as handlers, hooks, flow steps, websocket handlers, OAuth provider provisioning, and bootstrap scripts.',
56
- 'For admin UI lookup, use search_admin_extensions(mode="search") then search_admin_extensions(mode="inspect").',
57
- 'For focused extension edits, use patch_extension_code or update_extension_code.',
58
- 'If you intentionally read raw extension records, request fields such as ["id","name","type","version","code"].',
59
- ].join(' '));
60
- }
61
- // Import modules
62
- import { exchangeApiToken, getValidToken, getTokenExpiry, initAuth } from './lib/auth.js';
63
- import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
64
- import { fetchMetadataContext, fetchMetadataTables, fetchTableCatalog, fetchTableMetadata, fetchTableMetadataByRef, } from './lib/metadata-client.js';
65
- import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
66
- import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
67
- import { WORKFLOW_SURFACES, discoverWorkflowRoutes } from './lib/tool-routing.js';
68
- import { getSupportedColumnTypesFromMetadata, registerTableTools } from './lib/table-tools.js';
69
- import { registerPlatformOperationTools, validateExtensionCode } from './lib/platform-operation-tools.js';
70
- import { registerRuntimeZoneTools } from './lib/runtime-zone-tools.js';
71
- import { registerOAuthProviderTools } from './lib/oauth-tools.js';
72
- import { registerDynamicRepositoryBuilder } from './lib/dynamic-repository-builder.js';
73
- import { assertCreateHandlerRouteBoundary } from './lib/dynamic-endpoint-contract.js';
74
- import { assertGenericRecordMutationAllowed, parseRecordBatchData, parseRecordData, prepareRecordBatchMutation, prepareRecordMutation, validatePortableScriptSource, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
75
- import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAckIf, assertGlobalRulesAck, acknowledgeRequiredKnowledge, buildRequiredKnowledgePayload, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './lib/required-knowledge.js';
76
- import { validateMainTableRoutePath } from './lib/route-guards.js';
77
- import { assertRecordFieldsReadable, buildDeletePostcondition, buildQuerySchemaReceipt } from './lib/record-contracts.js';
78
- import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
79
- import { startMcpUsageTelemetry } from './lib/mcp-usage-telemetry.js';
80
- import { startRuntimeCacheSocket } from './lib/runtime-cache-socket.js';
81
- import { executeSequentialBatch } from './lib/sequential-batch.js';
82
- import { compactSourceFields, readSourceArtifactResource, writeSourceArtifact } from './lib/source-artifacts.js';
83
- import { installToolsetFilter, normalizeDynamicToolPacks, normalizeMcpProfile, normalizeMcpToolset, summarizeToolsetForInstructions } from './lib/toolset-filter.js';
84
- import { installToolAnnotations } from './lib/tool-contracts.js';
85
- import { installToolOutputContracts } from './lib/tool-output-contracts.js';
86
- import { registerToolCatalogTools } from './lib/tool-catalog.js';
87
- import { registerWorkflowToolPack } from './lib/workflow-tool-packs.js';
88
- import { findRoutePermission, mergeMethodNames, normalizeMethodNames, resolveRoleByNameOrId, routeAvailableMethodNames, routePublicMethodNames, summarizeRouteAccess, summarizeRoutePermission, validateMethodsForRoute, } from './lib/route-permission-tools.js';
89
- // Initialize auth module
90
- initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
91
- const MCP_TOOLSET = normalizeMcpToolset(process.env.ENFYRA_MCP_TOOLSET);
92
- const MCP_PROFILE = normalizeMcpProfile(process.env.ENFYRA_MCP_PROFILE);
93
- const MCP_DYNAMIC_TOOLS = normalizeDynamicToolPacks(process.env.ENFYRA_MCP_DYNAMIC_TOOLS, MCP_TOOLSET, MCP_PROFILE);
94
- const CAPABILITY_AREAS = [
95
- {
96
- area: 'Schema and metadata',
97
- tables: ['enfyra_table', 'enfyra_column', 'enfyra_relation', 'enfyra_schema_migration'],
98
- workflow: 'Use table tools for table/column/relation schema changes. enfyra_column and enfyra_session are internal/no-route; do not CRUD them directly.',
99
- },
100
- {
101
- area: 'Dynamic REST API',
102
- tables: ['enfyra_route', 'enfyra_route_handler', 'enfyra_pre_hook', 'enfyra_post_hook', 'enfyra_route_permission', 'enfyra_method'],
103
- workflow: 'Create custom paths with create_route without mainTableId, then add handlers/hooks. mainTableId is only for canonical table routes like /table_name. Query enfyra_method before assigning route methods.',
104
- },
105
- {
106
- area: 'Auth, roles, sessions, OAuth',
107
- tables: ['enfyra_user', 'enfyra_role', 'enfyra_api_token', 'enfyra_session', 'enfyra_oauth_config', 'enfyra_oauth_account'],
108
- workflow: 'MCP auth exchanges ENFYRA_API_TOKEN through /auth/token/exchange. Configure an API token from Enfyra admin UI /me.',
109
- },
110
- {
111
- area: 'Guards and permissions',
112
- tables: ['enfyra_guard', 'enfyra_guard_rule', 'enfyra_field_permission', 'enfyra_column_rule'],
113
- workflow: 'Use route guard metadata for request gating, field permissions for record field access, and column rules for body validation.',
114
- },
115
- {
116
- area: 'GraphQL',
117
- tables: ['enfyra_graphql'],
118
- workflow: 'Enable per table through enfyra_graphql or update_tables graphqlEnabled. GraphQL table data requires Bearer auth; anonymous root or schema probes may return 200 without exposing table data.',
119
- },
120
- {
121
- area: 'Files and storage',
122
- tables: ['enfyra_file', 'enfyra_file_permission', 'enfyra_folder', 'enfyra_storage_config'],
123
- workflow: 'Use file endpoints/helpers for uploads and asset streaming; metadata tables describe files, permissions, folders, and storage backends.',
124
- },
125
- {
126
- area: 'WebSocket',
127
- tables: ['enfyra_websocket', 'enfyra_websocket_event'],
128
- workflow: 'Socket.IO gateways/events are metadata-backed. Use admin test runner for handler scripts before relying on a real client.',
129
- },
130
- {
131
- area: 'Flows',
132
- tables: ['enfyra_flow', 'enfyra_flow_step', 'enfyra_flow_execution'],
133
- workflow: 'Create flows as small operation-sized steps via CRUD, test steps with test_flow_step/run_admin_test, trigger with trigger_flow. Split oversized scripts instead of adding more work to one step.',
134
- },
135
- {
136
- area: 'Extensions, menus, packages',
137
- tables: ['enfyra_extension', 'enfyra_menu', 'enfyra_package', 'enfyra_bootstrap_script'],
138
- workflow: 'Extensions are Vue SFC records. Use install_package for enfyra_package rather than raw CRUD.',
139
- },
140
- {
141
- area: 'Settings and platform config',
142
- tables: ['enfyra_setting', 'enfyra_cors_origin'],
143
- workflow: 'Settings and CORS origins are metadata-backed platform configuration.',
144
- },
145
- ];
146
- const FILTER_OPERATORS = [
147
- '_eq',
148
- '_neq',
149
- '_gt',
150
- '_gte',
151
- '_lt',
152
- '_lte',
153
- '_in',
154
- '_not_in',
155
- '_nin',
156
- '_contains',
157
- '_starts_with',
158
- '_ends_with',
159
- '_between',
160
- '_is_null',
161
- '_is_not_null',
162
- '_and',
163
- '_or',
164
- '_not',
165
- ];
166
- const DEFAULT_ME_PERMISSION_FIELDS = [
167
- 'id',
168
- 'email',
169
- 'isRootAdmin',
170
- 'role.id',
171
- 'role.name',
172
- 'role.routePermissions.id',
173
- 'role.routePermissions.isEnabled',
174
- 'role.routePermissions.methods.id',
175
- 'role.routePermissions.methods.name',
176
- 'role.routePermissions.route.id',
177
- 'role.routePermissions.route.path',
178
- 'role.routePermissions.allowedUsers.id',
179
- 'allowedRoutePermissions.id',
180
- 'allowedRoutePermissions.isEnabled',
181
- 'allowedRoutePermissions.methods.id',
182
- 'allowedRoutePermissions.methods.name',
183
- 'allowedRoutePermissions.route.id',
184
- 'allowedRoutePermissions.route.path',
185
- 'allowedRoutePermissions.allowedUsers.id',
186
- ];
187
- const MCP_PERMISSION_REQUIREMENTS = [
188
- {
189
- area: 'script validation',
190
- tools: ['validate_dynamic_script', 'create_handler', 'create_pre_hook', 'create_post_hook', 'patch_script_source', 'update_script_source', 'ensure_script_flow_step', 'ensure_condition_flow_step', 'ensure_websocket_event'],
191
- route: '/admin/script/validate',
192
- methods: ['POST'],
193
- },
194
- {
195
- area: 'flow and websocket test runner',
196
- tools: ['run_admin_test', 'test_flow_step'],
197
- route: '/admin/test/run',
198
- methods: ['POST'],
199
- },
200
- {
201
- area: 'manual flow trigger',
202
- tools: ['trigger_flow'],
203
- route: '/admin/flow/trigger/:id',
204
- methods: ['POST'],
205
- },
206
- {
207
- area: 'route cache reload',
208
- tools: ['reload_routes', 'enable_route', 'disable_route', 'delete_route', 'public_route_methods', 'private_route_methods', 'add_route_methods', 'replace_route_methods', 'remove_route_methods', 'ensure_route_access'],
209
- route: '/admin/reload/routes',
210
- methods: ['POST'],
211
- },
212
- {
213
- area: 'menu reorder',
214
- tools: ['reorder_menus'],
215
- route: '/admin/menu/reorder',
216
- methods: ['POST'],
217
- },
218
- {
219
- area: 'metadata cache reload',
220
- tools: ['reload_metadata'],
221
- route: '/admin/reload/metadata',
222
- methods: ['POST'],
223
- },
224
- {
225
- area: 'GraphQL cache reload',
226
- tools: ['reload_graphql', 'set_table_graphql'],
227
- route: '/admin/reload/graphql',
228
- methods: ['POST'],
229
- },
230
- {
231
- area: 'full cache reload',
232
- tools: ['reload_all'],
233
- route: '/admin/reload',
234
- methods: ['POST'],
235
- },
236
- ];
237
- const FIELD_PERMISSION_CONDITION_OPERATORS = [
238
- '_eq',
239
- '_neq',
240
- '_gt',
241
- '_gte',
242
- '_lt',
243
- '_lte',
244
- '_in',
245
- '_not_in',
246
- '_nin',
247
- '_is_null',
248
- '_is_not_null',
249
- '_and',
250
- '_or',
251
- '_not',
252
- ];
253
- const SCRIPT_BACKED_TABLES = [
254
- 'enfyra_route_handler',
255
- 'enfyra_pre_hook',
256
- 'enfyra_post_hook',
257
- 'enfyra_flow_step',
258
- 'enfyra_websocket_event',
259
- 'enfyra_websocket',
260
- 'enfyra_oauth_config',
261
- 'enfyra_bootstrap_script',
262
- ];
263
- const SCRIPT_BACKED_TABLE_SET = new Set(SCRIPT_BACKED_TABLES);
264
- const SCRIPT_SOURCE_FIELDS = [
265
- 'sourceCode',
266
- 'handlerScript',
267
- 'connectionHandlerScript',
268
- 'code',
269
- ];
270
- function getPrimaryColumn(table) {
271
- return (table?.columns || []).find((column) => column.isPrimary) || null;
272
- }
273
- function getMetadataDatabaseContext(metadata) {
274
- const dbType = metadata?.dbType || metadata?.data?.dbType || null;
275
- return {
276
- dbType,
277
- backendFamily: dbType === 'mongodb' ? 'mongodb' : dbType ? 'sql' : 'unknown',
278
- primaryKeyConvention: dbType === 'mongodb' ? '_id' : dbType ? 'id' : null,
279
- source: dbType ? 'GET /metadata' : 'unavailable',
280
- };
281
- }
282
- function summarizeTable(table) {
283
- if (!table)
284
- return null;
285
- const relationFkColumnNames = new Set((table.relations || []).flatMap((relation) => {
286
- const propertyName = relation.propertyName;
287
- return propertyName
288
- ? [
289
- `${propertyName}Id`,
290
- `${propertyName}_id`,
291
- relation.fkCol,
292
- relation.fkColumn,
293
- relation.foreignKeyColumn,
294
- ].filter(Boolean).map((name) => String(name).toLowerCase())
295
- : [];
296
- }));
297
- const modelFacingColumns = (table.columns || []).filter((column) => (column.isPrimary || !relationFkColumnNames.has(String(column.name || '').toLowerCase())));
298
- return {
299
- id: table.id ?? table._id,
300
- name: table.name,
301
- alias: table.alias,
302
- primaryKey: getPrimaryColumn(table)?.name || null,
303
- validateBody: table.validateBody,
304
- graphqlEnabled: table.graphqlEnabled,
305
- columns: modelFacingColumns.map((column) => ({
306
- id: column.id ?? column._id,
307
- name: column.name,
308
- type: column.type,
309
- isPrimary: !!column.isPrimary,
310
- isNullable: column.isNullable,
311
- isPublished: column.isPublished,
312
- isUpdatable: column.isUpdatable !== false,
313
- isEncrypted: column.isEncrypted === true,
314
- })),
315
- hiddenRelationColumnCount: (table.columns || []).length - modelFacingColumns.length,
316
- relations: (table.relations || []).map((relation) => ({
317
- id: relation.id ?? relation._id,
318
- propertyName: relation.propertyName,
319
- type: relation.type,
320
- targetTable: relation.targetTable?.name || relation.targetTableName || relation.targetTable,
321
- inversePropertyName: relation.inversePropertyName,
322
- mappedBy: relation.mappedBy?.propertyName || relation.mappedBy,
323
- isNullable: relation.isNullable,
324
- onDelete: relation.onDelete,
325
- isPublished: relation.isPublished,
326
- })),
327
- };
328
- }
329
- function summarizeRoutes(routesResult) {
330
- return (routesResult?.data || []).map((route) => ({
331
- id: route.id ?? route._id,
332
- path: route.path,
333
- mainTable: route.mainTable?.name || route.mainTableName || null,
334
- availableMethods: (route.availableMethods || []).map((method) => method.name).filter(Boolean),
335
- publicMethods: (route.publicMethods || []).map((method) => method.name).filter(Boolean),
336
- isEnabled: route.isEnabled,
337
- }));
338
- }
339
- function unwrapData(result) {
340
- return Array.isArray(result?.data) ? result.data : [];
341
- }
342
- function getId(record) {
343
- return record?.id ?? record?._id ?? null;
344
- }
345
- function sameId(a, b) {
346
- if (a === null || a === undefined || b === null || b === undefined)
347
- return false;
348
- return String(a) === String(b);
349
- }
350
- function refId(value) {
351
- return typeof value === 'object' && value !== null ? getId(value) : value;
352
- }
353
- function firstDataRecord(result) {
354
- return Array.isArray(result?.data) ? result.data[0] : result;
355
- }
356
- function resultRecordId(result) {
357
- return getId(firstDataRecord(result));
358
- }
359
- function normalizePermissionRoute(routePath) {
360
- const value = String(routePath || '').trim();
361
- return value.startsWith('/') ? value : `/${value}`;
362
- }
363
- function methodNames(permission) {
364
- return normalizeMethodNames((permission?.methods || []).map((method) => method?.name || method));
365
- }
366
- function permissionAllowedUserIds(permission) {
367
- return (permission?.allowedUsers || []).map((user) => String(refId(user))).filter(Boolean);
368
- }
369
- function permissionMatchesUser(permission, userId) {
370
- const allowed = permissionAllowedUserIds(permission);
371
- if (!allowed.length)
372
- return true;
373
- return userId ? allowed.includes(String(userId)) : false;
374
- }
375
- function directPermissionMatchesUser(permission, userId) {
376
- const allowed = permissionAllowedUserIds(permission);
377
- return userId ? allowed.includes(String(userId)) : false;
378
- }
379
- function userHasRoutePermission(user, routePath, method) {
380
- if (!user)
381
- return false;
382
- if (user.isRootAdmin)
383
- return true;
384
- const normalizedRoute = normalizePermissionRoute(routePath);
385
- const normalizedMethod = String(method || '').toUpperCase();
386
- const userId = getId(user);
387
- const directPermissions = user.allowedRoutePermissions || [];
388
- const rolePermissions = user.role?.routePermissions || [];
389
- const matchesRouteAndMethod = (permission) => (permission?.isEnabled !== false
390
- && permission?.route?.path === normalizedRoute
391
- && methodNames(permission).includes(normalizedMethod));
392
- return directPermissions.some((permission) => (matchesRouteAndMethod(permission)
393
- && directPermissionMatchesUser(permission, userId))) || rolePermissions.some((permission) => (matchesRouteAndMethod(permission)
394
- && permissionMatchesUser(permission, userId)));
395
- }
396
- function summarizePermissionProfile(user) {
397
- const requirements = MCP_PERMISSION_REQUIREMENTS.map((requirement) => {
398
- const methods = requirement.methods.map((method) => ({
399
- method,
400
- allowed: userHasRoutePermission(user, requirement.route, method),
401
- }));
402
- return {
403
- ...requirement,
404
- methods,
405
- allowed: methods.every((item) => item.allowed),
406
- };
407
- });
408
- return {
409
- user: user ? {
410
- id: getId(user),
411
- email: user.email || null,
412
- isRootAdmin: !!user.isRootAdmin,
413
- role: user.role ? {
414
- id: getId(user.role),
415
- name: user.role.name || null,
416
- } : null,
417
- } : null,
418
- permissionModel: {
419
- sameAsAdminUi: 'Mirrors Enfyra admin usePermissions(): root admin passes; otherwise direct allowedRoutePermissions are checked before role.routePermissions.',
420
- publicMethods: 'Anonymous REST access is controlled by route.publicMethods; this profile only reports authenticated route permissions for the configured token.',
421
- },
422
- mcpRequirements: requirements,
423
- missingRequirements: requirements
424
- .filter((item) => !item.allowed)
425
- .map((item) => ({
426
- area: item.area,
427
- route: item.route,
428
- methods: item.methods.filter((method) => !method.allowed).map((method) => method.method),
429
- tools: item.tools,
430
- })),
431
- };
432
- }
433
- async function resolveCatalogToolAvailability(toolNames) {
434
- const fields = DEFAULT_ME_PERMISSION_FIELDS.join(',');
435
- const result = await fetchAPI(ENFYRA_API_URL, `/me?fields=${encodeURIComponent(fields)}`);
436
- const user = firstDataRecord(result);
437
- if (user?.isRootAdmin) {
438
- return Object.fromEntries(toolNames.map((name) => [name, {
439
- status: 'allowed',
440
- reason: 'The configured PAT belongs to a root administrator.',
441
- }]));
442
- }
443
- const requirements = summarizePermissionProfile(user).mcpRequirements;
444
- return Object.fromEntries(toolNames.map((name) => {
445
- const requirement = requirements.find((item) => item.tools.includes(name));
446
- if (!requirement) {
447
- return [name, {
448
- status: 'unknown',
449
- reason: 'No static admin-route capability mapping exists for this tool; Enfyra PAT/RBAC remains authoritative at execution time.',
450
- }];
451
- }
452
- return [name, requirement.allowed
453
- ? { status: 'allowed', reason: `Current PAT grants ${requirement.methods.map((item) => item.method).join(', ')} ${requirement.route}.` }
454
- : { status: 'denied', reason: `Current PAT lacks one or more required methods on ${requirement.route}.` }];
455
- }));
456
- }
457
- function parseJsonArg(value, fallback = undefined) {
458
- if (value === undefined || value === null || value === '')
459
- return fallback;
460
- if (typeof value !== 'string')
461
- return value;
462
- return JSON.parse(value);
463
- }
464
- function stringifyJsonArg(value) {
465
- if (value === undefined || value === null || value === '')
466
- return undefined;
467
- return typeof value === 'string' ? value : JSON.stringify(value);
468
- }
469
- function applyDeepFieldSelections(fields, deep) {
470
- const selectedFields = [...fields];
471
- const parsedDeep = parseJsonArg(deep, null);
472
- if (!parsedDeep || typeof parsedDeep !== 'object' || Array.isArray(parsedDeep)) {
473
- return { fields: selectedFields, autoAdded: [] };
474
- }
475
- if (selectedFields.some((field) => String(field).startsWith('-'))) {
476
- return { fields: selectedFields, autoAdded: [] };
477
- }
478
- const autoAdded = [];
479
- for (const relationName of Object.keys(parsedDeep)) {
480
- const alreadySelected = selectedFields.some((field) => {
481
- const text = String(field);
482
- return text === relationName || text.startsWith(`${relationName}.`);
483
- });
484
- if (alreadySelected)
485
- continue;
486
- selectedFields.push(relationName);
487
- autoAdded.push(relationName);
488
- }
489
- return { fields: selectedFields, autoAdded };
490
- }
491
- async function reloadRoutesResult() {
492
- try {
493
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
494
- return {
495
- attempted: true,
496
- succeeded: true,
497
- result,
498
- };
499
- }
500
- catch (error) {
501
- return {
502
- attempted: true,
503
- succeeded: false,
504
- error: error?.message || String(error),
505
- };
506
- }
507
- }
508
- function normalizeRestPath(path) {
509
- if (!path)
510
- return '/';
511
- if (/^https?:\/\//i.test(path)) {
512
- throw new Error('Only Enfyra API paths are allowed, not full external URLs');
513
- }
514
- return path.startsWith('/') ? path : `/${path}`;
515
- }
516
- function pickCodeSummary(record, fieldName) {
517
- const code = record?.[fieldName];
518
- return {
519
- ...record,
520
- [fieldName]: typeof code === 'string'
521
- ? {
522
- length: code.length,
523
- preview: code.length > 700 ? `${code.slice(0, 700)}...` : code,
524
- }
525
- : code,
526
- };
527
- }
528
- function summarizeMutationResult(result, action, tableName) {
529
- const record = firstDataRecord(result);
530
- return {
531
- action,
532
- tableName,
533
- id: getId(record),
534
- statusCode: result?.statusCode,
535
- success: result?.success,
536
- detailHint: `Use find_one_record or query_table with explicit fields to inspect ${tableName}.`,
537
- };
538
- }
539
- async function getTableSummary(tableName) {
540
- return summarizeTable(await fetchTableMetadata(ENFYRA_API_URL, tableName));
541
- }
542
- async function getPrimaryFieldName(tableName, table = null) {
543
- const resolvedTable = table ?? await getTableSummary(tableName);
544
- if (resolvedTable?.primaryKey)
545
- return resolvedTable.primaryKey;
546
- const metadata = await fetchMetadataContext(ENFYRA_API_URL);
547
- return metadata.dbType === 'mongodb' ? '_id' : 'id';
548
- }
549
- async function fetchAll(path) {
550
- return unwrapData(await fetchAPI(ENFYRA_API_URL, path));
551
- }
552
- function targetInstance() {
553
- return {
554
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
555
- source: 'ENFYRA_API_URL environment variable used by this MCP server process',
556
- };
557
- }
558
- async function discoveryFetch(path, { fallbackData = [], timeoutMs = DISCOVERY_FETCH_TIMEOUT_MS } = {}) {
559
- let timeoutId;
560
- try {
561
- const timeout = new Promise((_, reject) => {
562
- timeoutId = setTimeout(() => {
563
- reject(new Error(`Discovery request timeout after ${timeoutMs}ms for ${path}`));
564
- }, timeoutMs);
565
- });
566
- return await Promise.race([
567
- fetchAPI(ENFYRA_API_URL, path),
568
- timeout,
569
- ]);
570
- }
571
- catch (error) {
572
- return {
573
- statusCode: null,
574
- success: false,
575
- error: String(error?.message || error),
576
- data: fallbackData,
577
- };
578
- }
579
- finally {
580
- if (timeoutId)
581
- clearTimeout(timeoutId);
582
- }
583
- }
584
- function collectPartialErrors(results) {
585
- return Object.entries(results)
586
- .filter(([, result]) => result?.error)
587
- .map(([name, result]) => ({ name, error: result.error }));
588
- }
589
- async function getMetadataTables(tableRef) {
590
- const metadata = await fetchMetadataContext(ENFYRA_API_URL);
591
- if (tableRef !== undefined && tableRef !== null && tableRef !== '') {
592
- return {
593
- metadata,
594
- tables: [await fetchTableMetadataByRef(ENFYRA_API_URL, tableRef)],
595
- };
596
- }
597
- const catalog = await fetchTableCatalog(ENFYRA_API_URL);
598
- return {
599
- metadata,
600
- tables: catalog,
601
- };
602
- }
603
- function resolveTableOrThrow(tables, tableName) {
604
- const table = tables.find((item) => item?.name === tableName || item?.alias === tableName);
605
- if (!table)
606
- throw new Error(`Unknown table "${tableName}"`);
607
- return table;
608
- }
609
- function resolveFieldOrThrow(table, fieldName, kind = 'column') {
610
- const list = kind === 'relation' ? table.relations || [] : table.columns || [];
611
- const field = list.find((item) => item.name === fieldName || item.propertyName === fieldName);
612
- if (!field)
613
- throw new Error(`Unknown ${kind} "${fieldName}" on table "${table.name}"`);
614
- return field;
615
- }
616
- async function prepareGenericMutation(tableName, data) {
617
- const { tables } = await getMetadataTables(tableName);
618
- return prepareRecordMutation({
619
- fetchAPI,
620
- apiUrl: ENFYRA_API_URL,
621
- tables,
622
- tableName,
623
- data,
624
- });
625
- }
626
- async function prepareGenericBatchMutation(tableName, records) {
627
- const { tables } = await getMetadataTables(tableName);
628
- return prepareRecordBatchMutation({
629
- fetchAPI,
630
- apiUrl: ENFYRA_API_URL,
631
- tables,
632
- tableName,
633
- records,
634
- });
635
- }
636
- function assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey }) {
637
- const payload = parseRecordData(data);
638
- assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
639
- assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
640
- }
641
- function assertKnowledgeForGenericBatchMutation(tableName, records, { knowledgeAckKey, extensionKnowledgeAckKey }) {
642
- const payloads = parseRecordBatchData(records);
643
- for (const payload of payloads) {
644
- assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
645
- assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
646
- }
647
- }
648
- function parseBulkItemsArg(name, value) {
649
- const parsed = typeof value === 'string' ? JSON.parse(value) : value;
650
- if (!Array.isArray(parsed)) {
651
- throw new Error(`${name} must be a JSON array. Pass one object in the array for a single mutation.`);
652
- }
653
- if (parsed.length === 0) {
654
- throw new Error(`${name} must include at least one item.`);
655
- }
656
- parsed.forEach((item, index) => {
657
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
658
- throw new Error(`${name}[${index}] must be a JSON object.`);
659
- }
660
- });
661
- return parsed;
662
- }
663
- function assertMaxBulkItems(name, items, maxItems) {
664
- if (items.length > maxItems) {
665
- throw new Error(`${name} received ${items.length} items, above maxItems=${maxItems}. Split the batch deliberately.`);
666
- }
667
- }
668
- function assertNoDuplicateBulkIds(name, items) {
669
- const seen = new Set();
670
- const duplicates = new Set();
671
- for (const item of items) {
672
- const id = String(item.id ?? '');
673
- if (!id)
674
- continue;
675
- if (seen.has(id))
676
- duplicates.add(id);
677
- seen.add(id);
678
- }
679
- if (duplicates.size > 0) {
680
- throw new Error(`${name} contains duplicate id(s): ${[...duplicates].join(', ')}. Split or merge duplicate writes so the sequential batch has one clear final mutation per record.`);
681
- }
682
- }
683
- async function validateExtensionCodeForGenericMutation(tableName, payload, fallbackName) {
684
- if (tableName !== 'enfyra_extension' || typeof payload?.code !== 'string')
685
- return null;
686
- return validateExtensionCode(ENFYRA_API_URL, payload.code, payload.name || fallbackName);
687
- }
688
- function parseQueryParamsArg(queryParams) {
689
- const parsed = parseJsonArg(queryParams, {});
690
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
691
- throw new Error('queryParams must be a JSON object string.');
692
- }
693
- const params = new URLSearchParams();
694
- for (const [key, value] of Object.entries(parsed)) {
695
- if (value === undefined || value === null)
696
- continue;
697
- params.set(key, String(value));
698
- }
699
- return params.toString();
700
- }
701
- function appendQuery(path, queryParams) {
702
- if (!queryParams)
703
- return path;
704
- return `${path}${path.includes('?') ? '&' : '?'}${queryParams}`;
705
- }
706
- const METHOD_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
707
- const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
708
- function normalizeMethodNameInput(method) {
709
- const value = String(method || '').trim().toUpperCase();
710
- if (!METHOD_NAME_RE.test(value)) {
711
- throw new Error('Method must start with A-Z and contain only uppercase letters, numbers, or underscore.');
712
- }
713
- return value;
714
- }
715
- function normalizeHexColorInput(value, fieldName) {
716
- const color = String(value || '').trim().toLowerCase();
717
- if (!HEX_COLOR_RE.test(color)) {
718
- throw new Error(`${fieldName} must be a full hex color such as #1d4ed8.`);
719
- }
720
- return color;
721
- }
722
- function sha256(value) {
723
- return createHash('sha256').update(String(value), 'utf8').digest('hex');
724
- }
725
- function getScriptSourceField(record) {
726
- for (const field of SCRIPT_SOURCE_FIELDS) {
727
- if (typeof record?.[field] === 'string')
728
- return field;
729
- }
730
- if (record?.config && typeof record.config === 'object' && typeof record.config.code === 'string') {
731
- return 'config.code';
732
- }
733
- return null;
734
- }
735
- function getRecordSource(record) {
736
- const field = getScriptSourceField(record);
737
- if (!field)
738
- return { field: null, sourceCode: '' };
739
- if (field === 'config.code')
740
- return { field, sourceCode: record.config.code };
741
- return { field, sourceCode: record[field] };
742
- }
743
- async function fetchRecordByPrimaryKey(tableName, id, fields = '*') {
744
- const primaryKey = await getPrimaryFieldName(tableName);
745
- const query = new URLSearchParams({
746
- filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
747
- limit: '1',
748
- fields,
749
- });
750
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`);
751
- const record = unwrapData(result)[0] || null;
752
- if (!record)
753
- throw new Error(`${tableName} record ${id} was not found.`);
754
- return { primaryKey, record };
755
- }
756
- async function fetchScriptRecord(tableName, id) {
757
- validateTableName(tableName);
758
- if (!SCRIPT_BACKED_TABLES.includes(tableName)) {
759
- throw new Error(`Unsupported script-backed table "${tableName}". Supported: ${SCRIPT_BACKED_TABLES.join(', ')}`);
760
- }
761
- const { primaryKey, record } = await fetchRecordByPrimaryKey(tableName, id, '*');
762
- const { field, sourceCode } = getRecordSource(record);
763
- if (!field) {
764
- throw new Error(`${tableName} record ${id} does not expose a known editable source field.`);
765
- }
766
- return { primaryKey, record, sourceField: field, sourceCode };
767
- }
768
- function countOccurrences(source, needle) {
769
- if (!needle)
770
- return 0;
771
- let count = 0;
772
- let index = 0;
773
- while (true) {
774
- index = source.indexOf(needle, index);
775
- if (index === -1)
776
- return count;
777
- count += 1;
778
- index += needle.length;
779
- }
780
- }
781
- function replaceOccurrence(source, oldText, newText, mode) {
782
- const occurrences = countOccurrences(source, oldText);
783
- if (occurrences === 0) {
784
- throw new Error('oldText was not found in the current source.');
785
- }
786
- if (mode === 'first') {
787
- return {
788
- occurrences,
789
- patched: source.replace(oldText, newText),
790
- replaced: 1,
791
- };
792
- }
793
- return {
794
- occurrences,
795
- patched: source.split(oldText).join(newText),
796
- replaced: occurrences,
797
- };
798
- }
799
- function sourcePreview(source, aroundText) {
800
- if (!aroundText)
801
- return source.slice(0, 1200);
802
- const index = source.indexOf(aroundText);
803
- if (index === -1)
804
- return source.slice(0, 1200);
805
- const start = Math.max(0, index - 500);
806
- const end = Math.min(source.length, index + aroundText.length + 500);
807
- return `${start > 0 ? '...' : ''}${source.slice(start, end)}${end < source.length ? '...' : ''}`;
808
- }
809
- function scriptRecordLabel(tableName, record) {
810
- const method = record.method?.name || null;
811
- const route = record.route?.path || null;
812
- const flow = record.flow?.name || null;
813
- const gateway = record.gateway?.path || null;
814
- return {
815
- tableName,
816
- id: getId(record),
817
- key: record.key || record.name || record.eventName || record.provider || null,
818
- route,
819
- method,
820
- flow,
821
- gateway,
822
- };
823
- }
824
- function scriptTraceFields(tableName) {
825
- const common = 'id,_id,name,key,eventName,sourceCode,handlerScript,connectionHandlerScript,code,scriptLanguage';
826
- const byTable = {
827
- enfyra_route_handler: `${common},route.id,route.path,method.id,method.name`,
828
- enfyra_pre_hook: `${common},route.id,route.path,methods.id,methods.name,isGlobal`,
829
- enfyra_post_hook: `${common},route.id,route.path,methods.id,methods.name,isGlobal`,
830
- enfyra_flow_step: `${common},flow.id,flow.name`,
831
- enfyra_websocket_event: `${common},gateway.id,gateway.path`,
832
- enfyra_websocket: `${common},path`,
833
- enfyra_oauth_config: `${common},provider,redirectUri,appCallbackUrl,autoSetCookies,isEnabled`,
834
- enfyra_bootstrap_script: common,
835
- };
836
- return byTable[tableName] || '*';
837
- }
838
- async function findMethodRecordByName(method) {
839
- const filter = encodeURIComponent(JSON.stringify({ name: { _eq: method } }));
840
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
841
- return unwrapData(result)[0] || null;
842
- }
843
- // Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
844
- const server = new McpServer({
845
- name: 'enfyra-mcp',
846
- version: '1.0.0',
847
- }, {
848
- instructions: buildMcpServerInstructions(ENFYRA_API_URL, {
849
- toolsetSummary: summarizeToolsetForInstructions(MCP_TOOLSET, MCP_PROFILE, MCP_DYNAMIC_TOOLS),
850
- }),
851
- });
852
- installToolOutputContracts(server);
853
- installColumnarToolFormatter(server);
854
- const toolsetState = installToolsetFilter(server, MCP_TOOLSET, MCP_PROFILE, { dynamic: MCP_DYNAMIC_TOOLS });
855
- installToolAnnotations(server);
856
- startMcpUsageTelemetry(ENFYRA_API_URL, `${MCP_TOOLSET}:${MCP_PROFILE}`);
857
- server.registerResource('enfyra-source-artifact', new ResourceTemplate('enfyra-source://artifact/{artifactId}', { list: undefined }), {
858
- title: 'Enfyra source artifact',
859
- description: 'Process-scoped source or diff artifact created by an Enfyra MCP inspect or preview tool.',
860
- mimeType: 'text/plain',
861
- }, async (uri) => ({ contents: [readSourceArtifactResource(uri.href)] }));
862
- // ============================================================================
863
- // METADATA TOOLS
864
- // ============================================================================
865
- server.tool('get_enfyra_required_knowledge', [
866
- 'Return required Enfyra knowledge and acknowledgement keys for MCP code-writing tools.',
867
- 'Call this before creating or updating dynamic server code or Enfyra extension code. Read the returned contracts and pass the matching ack key into write tools.',
868
- 'Pass scope to only load rules for the current task domain: "schema" (table/data/route/permission/guard work), "dynamic-code" (handler/hook/websocket/resolver scripts), "extension" (admin UI/menu/shell), or "flow". Omitting scope returns all rules.',
869
- ].join(' '), {
870
- scope: z.enum(['full', 'schema', 'dynamic-code', 'extension', 'flow']).optional().describe('Limit knowledge to one domain. Use full or omit scope to load all rules.'),
871
- }, async ({ scope }) => {
872
- const payload = buildRequiredKnowledgePayload(scope);
873
- const sessionAcknowledgement = acknowledgeRequiredKnowledge(scope);
874
- return jsonContent({ ...payload, sessionAcknowledgement });
875
- });
876
- server.tool('get_all_metadata', 'Get a lightweight table catalog. Use get_table_metadata or inspect_table to fetch one table schema.', {
877
- includeFull: z.boolean().optional().default(false).describe('Fetch per-table metadata for the selected catalog entries. Default false keeps discovery lightweight.'),
878
- search: z.string().optional().describe('Optional table-name/alias substring filter.'),
879
- limit: z.number().optional().describe('Maximum tables returned after search. Default 30.'),
880
- all: z.boolean().optional().default(false).describe('Return every matched table summary. Use when a complete table list is required.'),
881
- }, async ({ includeFull, search, limit, all }) => {
882
- if (all && limit !== undefined) {
883
- throw new Error('get_all_metadata accepts either all=true or limit, not both.');
884
- }
885
- const [context, catalog] = await Promise.all([
886
- fetchMetadataContext(ENFYRA_API_URL),
887
- fetchTableCatalog(ENFYRA_API_URL),
888
- ]);
889
- const q = search?.trim().toLowerCase();
890
- const matched = catalog.filter((table) => !q || [table.name, table.alias, table.description]
891
- .some((value) => String(value || '').toLowerCase().includes(q)));
892
- const outputLimit = all ? matched.length : (limit || 30);
893
- const selected = matched.slice(0, outputLimit);
894
- const payload = {
895
- context,
896
- tableCount: catalog.length,
897
- matchedTableCount: matched.length,
898
- returnedTableCount: selected.length,
899
- complete: all || outputLimit >= matched.length,
900
- hardCap: all ? null : outputLimit,
901
- search: search || null,
902
- tables: includeFull
903
- ? await fetchMetadataTables(ENFYRA_API_URL, selected)
904
- : selected.map((table) => ({
905
- id: table.id ?? table._id,
906
- name: table.name,
907
- alias: table.alias ?? null,
908
- description: table.description ?? null,
909
- isSingleRecord: table.isSingleRecord ?? null,
910
- detailHint: `Use get_table_metadata({ tableName: "${table.name}" }) for columns and relations.`,
911
- })),
912
- detailHint: includeFull
913
- ? 'Full permission-projected metadata was fetched per selected table.'
914
- : 'Catalog only. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for schema detail.',
915
- };
916
- return jsonContent(payload);
917
- });
918
- server.tool('get_table_metadata', 'Get concise metadata for a specific table by name', {
919
- tableName: z.string().describe('Table name (e.g., "enfyra_user", "enfyra_route")'),
920
- includeFull: z.boolean().optional().default(false).describe('Return full raw table metadata. Default false to keep MCP context small.'),
921
- }, async ({ tableName, includeFull }) => {
922
- const table = await fetchTableMetadata(ENFYRA_API_URL, tableName);
923
- const payload = includeFull
924
- ? { data: table, ...await fetchMetadataContext(ENFYRA_API_URL) }
925
- : {
926
- table: summarizeTable(table),
927
- queryHint: `Use query_table({ tableName: "${tableName}", fields: [...] }) for records. query_table without fields returns only the primary key.`,
928
- };
929
- return jsonContent(payload);
930
- });
931
- server.tool('get_enfyra_examples', [
932
- 'Return concrete Enfyra examples by category.',
933
- 'Use this before generating schemas, queries, handlers/hooks, third-app connections, OAuth, Socket.IO, flows, files, or extensions so implementation details follow proven patterns.',
934
- ].join(' '), {
935
- category: z.enum(asNonEmptyStringTuple(listExampleCategories().map((item) => item.key), 'Example categories')).optional().describe('Example category key. Omit to list categories.'),
936
- }, async ({ category }) => {
937
- const result = getExamples(category);
938
- return jsonContent(result);
939
- });
940
- server.tool('discover_enfyra_workflows', [
941
- 'Progressive-disclosure router for the Enfyra MCP tool surface.',
942
- 'Call this when the task intent is clear but the right Enfyra tool path is not.',
943
- 'Returns matched workflows, first tools, required acknowledgement keys, verification tools, and avoidTools negative-routing boundaries.',
944
- ].join(' '), {
945
- intent: z.string().optional().describe('Plain-language task goal, e.g. "add a menu chip when support tickets arrive".'),
946
- surface: z.enum(WORKFLOW_SURFACES).optional().describe('Known surface when the caller can classify the task. Omit to infer from intent.'),
947
- risk: z.string().optional().default('unknown').describe('Highest expected operation risk. Preferred values: read, write, destructive, debug, unknown. Natural terms such as low, medium, or high are accepted and normalized.'),
948
- detail: z.enum(['summary', 'plan', 'full']).optional().default('summary').describe('summary lists candidate workflows; plan adds tool sequence and avoidTools; full also includes matching keywords.'),
949
- limit: z.number().int().positive().max(10).optional().default(5).describe('Maximum workflows to return.'),
950
- }, async (input) => jsonContent(discoverWorkflowRoutes(input, MCP_PROFILE, MCP_DYNAMIC_TOOLS)));
951
- server.tool('discover_enfyra_system', [
952
- 'Call this first when you need to understand the live Enfyra instance.',
953
- 'Returns a concise capability map from live metadata/routes/method rows, including schema management, REST route behavior, GraphQL enablement, and relation handling.',
954
- 'Do not use this only to confirm the API base; use get_enfyra_api_context for that cheaper target check.',
955
- 'Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
956
- ].join(' '), {}, async () => {
957
- const metadata = await discoveryFetch('/metadata');
958
- const tableCatalogResult = await discoveryFetch('/enfyra_table?fields=id,name,alias,description,isSingleRecord&limit=0&sort=name');
959
- const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*&limit=1000');
960
- const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
961
- const columnMetadata = await discoveryFetch('/metadata/enfyra_column', { fallbackData: null });
962
- const relationMetadata = await discoveryFetch('/metadata/enfyra_relation', { fallbackData: null });
963
- const tableMetadata = await discoveryFetch('/metadata/enfyra_table', { fallbackData: null });
964
- const graphqlMetadata = await discoveryFetch('/metadata/enfyra_graphql', { fallbackData: null });
965
- const tables = unwrapData(tableCatalogResult);
966
- const tableNames = tables.map((table) => table?.name).filter(Boolean).sort();
967
- const routes = summarizeRoutes(routesResult);
968
- const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
969
- const noRouteTables = tableNames.filter((name) => !routeTables.has(name));
970
- const relationTable = relationMetadata?.data || null;
971
- const tableDefinition = tableMetadata?.data || null;
972
- const gqlDefinition = graphqlMetadata?.data || null;
973
- const routeTableList = [...routeTables].sort();
974
- const noRouteTableList = noRouteTables.sort();
975
- const sample = (items, max = 40) => ({
976
- total: items.length,
977
- returned: Math.min(items.length, max),
978
- items: items.slice(0, max),
979
- truncated: items.length > max,
980
- });
981
- const payload = {
982
- targetInstance: targetInstance(),
983
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
984
- partialErrors: collectPartialErrors({ metadata, tableCatalogResult, routesResult, methodsResult, columnMetadata, relationMetadata, tableMetadata, graphqlMetadata }),
985
- counts: {
986
- tables: tableNames.length,
987
- routes: routes.length,
988
- methods: methodsResult?.data?.length || 0,
989
- },
990
- methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name })),
991
- capabilityAreas: CAPABILITY_AREAS.map((item) => ({
992
- ...item,
993
- presentTables: item.tables.filter((table) => tableNames.includes(table)),
994
- routeBackedTables: item.tables.filter((table) => routeTables.has(table)),
995
- noRouteTables: item.tables.filter((table) => tableNames.includes(table) && !routeTables.has(table)),
996
- })),
997
- rest: {
998
- routePattern: 'Dynamic REST routes expose GET/POST at /<route-path> and PATCH/DELETE at /<route-path>/:id; there is no GET /<route-path>/:id.',
999
- publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
1000
- routeTables: sample(routeTableList),
1001
- noRouteTables: sample(noRouteTableList),
1002
- canonicalCrudTools: 'query_table reads route-backed tables. create_records/update_records/delete_records are the only generic write tools; pass native arrays even for one item. They preflight arrays and run sequentially.',
1003
- 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.',
1004
- routeSamples: sample(routes, 25),
1005
- detailHint: 'Use get_all_routes({ search, limit }) or inspect_route({ path }) for route details. Use inspect_table({ tableName }) for table detail.',
1006
- },
1007
- schemaManagement: {
1008
- createTable: 'POST /enfyra_table supports isSingleRecord at create time. MCP create_tables accepts a native array, creates tables/columns sequentially, then creates requested relations after all tables in the batch exist. It does not accept alias at create time; table name drives the default route/schema behavior.',
1009
- updateTable: 'PATCH /enfyra_table/:id is the canonical path for table property changes and column/relation schema changes.',
1010
- columns: 'enfyra_column has no REST route; use create_tables/create_columns/update_columns/delete_columns. Use liveColumnTypes below; do not invent SQL dialect names.',
1011
- liveColumnTypes: getSupportedColumnTypesFromMetadata(columnMetadata),
1012
- columnTypeGuidance: 'Use varchar for short strings, text/richtext for long prose, float for price/amount/rating/decimal-like values unless decimal is listed, simple-json for structured objects/arrays only when listed, and relations instead of *_id columns for links.',
1013
- relations: routeTables.has('enfyra_relation')
1014
- ? 'enfyra_relation has a REST route for reads/metadata, but canonical schema migration is create_relations/delete_relations or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.'
1015
- : 'Use create_relations/delete_relations or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.',
1016
- relationCascadeFkContract: 'Do not ask for or send physical FK/junction column names in relation create/update payloads. Enfyra derives fk/junction columns from relation propertyName/table metadata and hides FK columns from app schema/forms. Use targetTable, type, propertyName, inversePropertyName or mappedBy, isNullable, onDelete. Add inversePropertyName only when a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use the reverse field.',
1017
- tableDefinitionRelations: (tableDefinition?.relations || []).map((rel) => rel.propertyName),
1018
- relationDefinitionRelations: (relationTable?.relations || []).map((rel) => rel.propertyName),
1019
- },
1020
- adminTesting: {
1021
- runAdminTest: 'run_admin_test wraps POST /admin/test/run for flow_step, websocket_event, and websocket_connection scripts.',
1022
- testFlowStep: 'test_flow_step also wraps POST /admin/test/run with kind=flow_step.',
1023
- triggerFlow: 'trigger_flow resolves a saved enabled flow, then wraps POST /admin/flow/trigger/:id. Use test_flow_step for disabled flows.',
1024
- },
1025
- graphql: {
1026
- endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
1027
- schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
1028
- enablement: 'A table appears in GraphQL when enfyra_graphql has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
1029
- auth: 'GraphQL table data requires Authorization: Bearer <accessToken>; REST publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
1030
- management: routeTables.has('enfyra_graphql')
1031
- ? 'Use update_tables graphqlEnabled or create_records/update_records on enfyra_graphql, then reload_graphql if needed.'
1032
- : 'Use update_tables graphqlEnabled, then reload_graphql if needed.',
1033
- gqlDefinitionColumns: (gqlDefinition?.columns || []).map((column) => column.name),
1034
- },
1035
- tableSamples: sample(tableNames, 40),
1036
- };
1037
- return jsonContent(payload);
1038
- });
1039
- server.tool('discover_runtime_context', [
1040
- 'Discover live runtime context that affects how an LLM should use Enfyra.',
1041
- 'Reports exact database type, the derived primary-key convention, route/cache/admin surfaces, and active metadata-backed runtime areas. Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
1042
- ].join(' '), {}, async () => {
1043
- const metadata = await discoveryFetch('/metadata');
1044
- const tableCatalogResult = await discoveryFetch('/enfyra_table?fields=id,name,alias,description,isSingleRecord&limit=0&sort=name');
1045
- const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
1046
- const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
1047
- const gqlResult = await discoveryFetch('/enfyra_graphql?limit=1000');
1048
- const flowsResult = await discoveryFetch('/enfyra_flow?limit=1000');
1049
- const websocketResult = await discoveryFetch('/enfyra_websocket?limit=1000');
1050
- const storageResult = await discoveryFetch('/enfyra_storage_config?limit=1000');
1051
- const settingsResult = await discoveryFetch('/enfyra_setting?limit=1000');
1052
- const meResult = await discoveryFetch('/me', { fallbackData: null });
1053
- const tables = unwrapData(tableCatalogResult);
1054
- const routes = summarizeRoutes(routesResult);
1055
- const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
1056
- const adminRoutes = routes.filter((route) => route.path?.startsWith('/admin'));
1057
- const publicRoutes = routes.filter((route) => route.publicMethods?.length);
1058
- const sample = (items, max = 25) => ({
1059
- total: items.length,
1060
- returned: Math.min(items.length, max),
1061
- items: items.slice(0, max),
1062
- truncated: items.length > max,
1063
- });
1064
- const payload = {
1065
- targetInstance: targetInstance(),
1066
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
1067
- partialErrors: collectPartialErrors({
1068
- metadata,
1069
- tableCatalogResult,
1070
- routesResult,
1071
- methodsResult,
1072
- gqlResult,
1073
- flowsResult,
1074
- websocketResult,
1075
- storageResult,
1076
- settingsResult,
1077
- meResult,
1078
- }),
1079
- authenticatedUser: Array.isArray(meResult?.data) ? meResult.data[0] || null : meResult?.data || null,
1080
- database: getMetadataDatabaseContext(metadata),
1081
- counts: {
1082
- tables: tables.length,
1083
- routes: routes.length,
1084
- routeBackedTables: routeTables.size,
1085
- noRouteTables: tables.filter((table) => !routeTables.has(table.name)).length,
1086
- methods: methodsResult?.data?.length || 0,
1087
- graphqlDefinitions: gqlResult?.data?.length || 0,
1088
- enabledGraphqlDefinitions: (gqlResult?.data || []).filter((row) => row.isEnabled !== false).length,
1089
- flows: flowsResult?.data?.length || 0,
1090
- enabledFlows: (flowsResult?.data || []).filter((row) => row.isEnabled !== false).length,
1091
- websocketGateways: websocketResult?.data?.length || 0,
1092
- enabledWebsocketGateways: (websocketResult?.data || []).filter((row) => row.isEnabled !== false).length,
1093
- storageConfigs: storageResult?.data?.length || 0,
1094
- settings: settingsResult?.data?.length || 0,
1095
- },
1096
- methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name })),
1097
- routeRuntime: {
1098
- routePattern: 'GET/POST /<route-path>; PATCH/DELETE /<route-path>/:id; no dynamic GET /<route-path>/:id.',
1099
- adminRoutes: sample(adminRoutes.map((route) => route.path).sort()),
1100
- publicRoutes: sample(publicRoutes.map((route) => ({
1101
- path: route.path,
1102
- mainTable: route.mainTable,
1103
- publicMethods: route.publicMethods,
1104
- }))),
1105
- },
1106
- cacheAndCluster: {
1107
- metadataMutationReloads: 'Metadata-backed mutations emit cache invalidation; admin reload endpoints exist for metadata/routes/graphql/guards/all.',
1108
- runtimeCacheContract: 'REDIS_RUNTIME_CACHE=true stores runtime definition snapshots in Redis so instances with the same NODE_NAME read the same runtime cache namespace.',
1109
- userCacheContract: '$cache/@CACHE uses managed user cache under NODE_NAME:user_cache:* with REDIS_USER_CACHE_LIMIT_MB default 30 MB; quota eviction only removes user cache keys, not runtime cache, BullMQ, Socket.IO, telemetry, or lock keys.',
1110
- multiInstanceContract: 'Backend is cluster-aware through cache invalidation, Redis runtime cache, Redis user cache, and BullMQ paths, but this MCP can only observe metadata/API state, not every node health.',
1111
- flowWorkerContract: 'Flow jobs require the backend flow worker to be initialized after HTTP listen and websocket gateway init; trigger_flow only confirms enqueue/result from admin endpoint.',
1112
- },
1113
- runtimeGaps: [
1114
- metadata?.dbType || metadata?.data?.dbType ? null : 'Exact database type was unavailable from GET /metadata.',
1115
- 'Redis/BullMQ/socket adapter health is not exposed by current MCP-visible API.',
1116
- '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.',
1117
- ].filter(Boolean),
1118
- };
1119
- return jsonContent(payload);
1120
- });
1121
- server.tool('discover_query_capabilities', [
1122
- 'Discover Enfyra query/filter/deep-fetch capabilities for the live instance.',
1123
- 'Prefer passing tableName. Without tableName this returns only generic query rules. Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
1124
- ].join(' '), {
1125
- tableName: z.string().optional().describe('Optional table name to summarize query fields and relation/deep capabilities.'),
1126
- }, async ({ tableName }) => {
1127
- const metadata = tableName
1128
- ? await discoveryFetch(`/metadata/${encodeURIComponent(tableName)}`)
1129
- : null;
1130
- const routesResult = tableName
1131
- ? await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000')
1132
- : { data: [] };
1133
- const tableFromMetadata = tableName && !metadata?.error
1134
- ? metadata?.data?.table || metadata?.data || metadata?.table || metadata
1135
- : null;
1136
- const tables = tableName
1137
- ? (tableFromMetadata ? [tableFromMetadata] : [])
1138
- : [];
1139
- const routes = summarizeRoutes(routesResult);
1140
- const table = tableName ? tables.find((item) => item.name === tableName) : null;
1141
- const primaryKey = table ? getPrimaryColumn(table)?.name || null : null;
1142
- const tableRoutes = tableName
1143
- ? routes.filter((route) => route.mainTable === tableName)
1144
- : [];
1145
- const payload = {
1146
- targetInstance: targetInstance(),
1147
- partialErrors: collectPartialErrors({ metadata, routesResult }),
1148
- operators: {
1149
- filter: FILTER_OPERATORS,
1150
- fieldPermissionConditions: FIELD_PERMISSION_CONDITION_OPERATORS,
1151
- fieldPermissionConditionUnsupported: ['_contains', '_starts_with', '_ends_with', '_between'],
1152
- },
1153
- queryParams: {
1154
- fields: 'Comma-separated scalar/relation fields. Relations use relation propertyName, not physical FK column names.',
1155
- filter: 'JSON object using operators above. Relation filters use nested relation propertyName objects.',
1156
- sort: 'Local field or -field. For direct one-to-many/many-to-many parent ordering, use _count(relation), _max(relation.field), or _min(relation.field); raw dotted to-many sort is invalid.',
1157
- page: '1-based page.',
1158
- limit: 'Page size.',
1159
- meta: 'Request metadata/counts where supported.',
1160
- deep: 'Nested relation fetch object keyed by relation propertyName.',
1161
- },
1162
- countPattern: `For counts, query only fields=${primaryKey || '<primary-key>'} with limit=1 and request meta. Use meta=totalCount without a filter, or meta=filterCount when a filter is supplied. MCP count_records resolves the live table primary key and wraps this pattern.`,
1163
- security: 'Filters, sorts, counts, and aggregate values can leak information even when a field is not selected. In generated public/user-facing APIs, do not filter, sort, count, or aggregate unpublished fields or private relations unless the endpoint intentionally exposes that fact.',
1164
- deep: {
1165
- shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
1166
- mcpFieldProjection: 'query_table auto-adds missing top-level deep relation names to fields unless fields are in exclude mode, so the nested relation can appear in the response.',
1167
- rules: [
1168
- 'Unknown relation keys are invalid.',
1169
- 'Unknown deep entry keys are invalid.',
1170
- 'limit on many-to-one/one-to-one relations is invalid.',
1171
- 'Dotted sort through one-to-many/many-to-many is invalid.',
1172
- 'Deep sort orders rows inside the related collection only; use root aggregate sort helpers when parent rows must be ordered by child values.',
1173
- 'Nested deep is recursively validated.',
1174
- 'Field permissions may rewrite filters/sorts and sanitize post-query results.',
1175
- ],
1176
- },
1177
- backendNotes: {
1178
- primaryKey: tableName
1179
- ? 'Use this table metadata primary column when available.'
1180
- : 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
1181
- relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
1182
- relationCascadeFkContract: 'When creating relations through create_tables/create_relations/enfyra_table PATCH, never provide fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. These are physical implementation details derived by Enfyra and hidden from app schema/forms. Add inversePropertyName only for a concrete reverse traversal such as parent deep child lists, response fields, UI sections, or aggregate sort/count.',
1183
- graphql: 'GraphQL query args also accept filter/sort/page/limit. Table data requires Bearer auth and table enablement via enfyra_graphql; anonymous root/schema probes may still return 200.',
1184
- },
1185
- table: tableName
1186
- ? {
1187
- exists: !!table,
1188
- metadata: summarizeTable(table),
1189
- routes: tableRoutes,
1190
- examples: table
1191
- ? {
1192
- list: `GET /${tableRoutes[0]?.path?.replace(/^\//, '') || table.name}?limit=10`,
1193
- oneByPkFilter: { [primaryKey]: { _eq: '<id>' } },
1194
- relationDeep: (table.relations || [])[0]
1195
- ? { [(table.relations || [])[0].propertyName]: { fields: ['id'], limit: 5 } }
1196
- : null,
1197
- relationFilter: (table.relations || [])[0]
1198
- ? { [(table.relations || [])[0].propertyName]: { [primaryKey]: { _eq: '<related-id>' } } }
1199
- : null,
1200
- }
1201
- : null,
1202
- }
1203
- : null,
1204
- discoveryRule: 'When building a query, inspect table metadata first, then use relation propertyName and primary column from that metadata.',
1205
- };
1206
- return jsonContent(payload);
1207
- });
1208
- server.tool('discover_script_contexts', [
1209
- 'Discover runtime script contexts and macro availability for handlers, hooks, flows, websocket scripts, GraphQL, packages, and extensions.',
1210
- 'Use before writing dynamic JavaScript logic so the model does not mix context variables across surfaces. This tool is static and safe to call alone; avoid running it in parallel with other broad discovery calls.',
1211
- ].join(' '), {}, async () => {
1212
- const payload = {
1213
- targetInstance: targetInstance(),
1214
- transformer: {
1215
- rule: 'Dynamic server scripts are transformed before sandbox execution. Macros expand to $ctx paths; comments are not transformed.',
1216
- preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use macros such as @BODY/@QUERY/@PARAMS/@USER/@REQ/@RES/@REPOS/@CACHE/@HELPERS/@FETCH/@STORAGE/@UPLOADED_FILE/@SOCKET/@TRIGGER/@DATA/@ERROR/@STATUS/@ENV/@PKGS/@LOGS/@SHARE/@API/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
1217
- coreMacros: {
1218
- '@CACHE': '$ctx.$cache',
1219
- '@REPOS': '$ctx.$repos',
1220
- '@HELPERS': '$ctx.$helpers',
1221
- '@STORAGE': '$ctx.$storage',
1222
- '@FETCH': '$ctx.$helpers.$fetch',
1223
- '@LOGS': '$ctx.$logs',
1224
- '@BODY': '$ctx.$body',
1225
- '@ENV': '$ctx.$env',
1226
- '@DATA': '$ctx.$data',
1227
- '@PARAMS': '$ctx.$params',
1228
- '@QUERY': '$ctx.$query',
1229
- '@USER': '$ctx.$user',
1230
- '@REQ': '$ctx.$req',
1231
- '@RES': '$ctx.$res',
1232
- '@SHARE': '$ctx.$share',
1233
- '@API': '$ctx.$api',
1234
- '@UPLOADED_FILE': '$ctx.$uploadedFile',
1235
- '@PKGS': '$ctx.$pkgs',
1236
- '@SOCKET': '$ctx.$socket',
1237
- '@TRIGGER': '$ctx.$trigger',
1238
- '@FLOW': '$ctx.$flow',
1239
- '@FLOW_PAYLOAD': '$ctx.$flow.$payload',
1240
- '@FLOW_LAST': '$ctx.$flow.$last',
1241
- '@FLOW_META': '$ctx.$flow.$meta',
1242
- '@THROW400': "$ctx.$throw['400']",
1243
- '@THROW401': "$ctx.$throw['401']",
1244
- '@THROW403': "$ctx.$throw['403']",
1245
- '@THROW404': "$ctx.$throw['404']",
1246
- '@THROW409': "$ctx.$throw['409']",
1247
- '@THROW422': "$ctx.$throw['422']",
1248
- '@THROW429': "$ctx.$throw['429']",
1249
- '@THROW500': "$ctx.$throw['500']",
1250
- '@THROW503': "$ctx.$throw['503']",
1251
- '@THROW': '$ctx.$throw',
1252
- '@STATUS': '$ctx.$statusCode',
1253
- '@ERROR': '$ctx.$error',
1254
- },
1255
- logging: '@LOGS is a callable function. Use @LOGS(message, details?) such as @LOGS("Approval requested", { requestId }); do not use @LOGS.info, @LOGS.warn, @LOGS.error, or @LOGS.debug.',
1256
- socket: {
1257
- contract: '@SOCKET has no generic emit() method.',
1258
- boundWebsocketMethods: ['reply(event, data)', 'join(room)', 'leave(room)', 'emitToCurrentRoom(room, event, data)', 'broadcastToRoom(room, event, data)', 'disconnect()'],
1259
- globalMethods: ['emitToGateway(path, event, data)', 'emitToRoom(path, room, event, data)', 'emitToUser(userId, event, data)', 'broadcast(event, data)', 'roomSize(room)'],
1260
- },
1261
- flowMacros: {
1262
- '@FLOW': '$ctx.$flow',
1263
- '@FLOW_PAYLOAD': '$ctx.$flow.$payload',
1264
- '@FLOW_LAST': '$ctx.$flow.$last',
1265
- '@FLOW_META': '$ctx.$flow.$meta',
1266
- '#table_name': '$ctx.$repos.table_name',
1267
- },
1268
- cache: {
1269
- contract: '@CACHE and $ctx.$cache use managed user cache. Use logical keys only; Enfyra stores Redis-backed user cache under NODE_NAME:user_cache:* and Redis Admin Key Editor uses the same storage path.',
1270
- quota: 'REDIS_USER_CACHE_LIMIT_MB defaults to 30 MB. If exceeded, Enfyra evicts least-recently-used user-cache keys only; system Redis keys are not counted or evicted.',
1271
- keyRule: 'Do not include NODE_NAME, user_cache:, or Redis namespace prefixes in scripts. Prefer TTL-based set(key, value, ttlMs); setNoExpire may still be evicted by the user-cache soft allocation.',
1272
- },
1273
- throws: '@THROW maps to $ctx.$throw. Numeric helpers are raw HTTP message helpers: @THROW400(message), @THROW404(message), @THROW409(message), @THROW422(message, detailsObject?), @THROW500(message). Numeric helper details must be an object/array, e.g. @THROW404("Project not found", { id }); do not use @THROW404("Project", id) as a semantic shortcut. Use @THROW.http(status, message, details?) for dynamic status codes. Use @THROW.notFound(resource, id?) and @THROW.duplicate(resource, field, value) only when you intentionally want Enfyra-formatted semantic messages.',
1274
- helpers: {
1275
- core: '$ctx.$helpers includes $bcrypt.hash/compare, autoSlug(text), $fetch, $sleep(ms) capped by the runtime, and $crypto. HTTP and GraphQL contexts also expose $jwt through $ctx.$helpers. Every helper method crosses the async executor bridge: await its result before property access, interpolation, concatenation, or persistence.',
1276
- fetch: '@FETCH maps to $ctx.$helpers.$fetch for outbound HTTP calls from server scripts. Keep secrets in encrypted fields instead of embedding them in sourceCode.',
1277
- crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Await every call, including helpers whose host implementation is synchronous, for example const id = await @HELPERS.$crypto.randomUUID(). Use generateSshKeyPair for SSH key material. Do not use legacy $ctx.$helpers.$ssh.',
1278
- files: '$ctx.$storage.$upload and $ctx.$storage.$update accept file: @UPLOADED_FILE for request uploads and stream from the server temp file path. $ctx.$storage.$registerFile creates a enfyra_file record for an object that already exists in storage without uploading bytes. Use buffer only for small generated/transformed files; do not use @UPLOADED_FILE.buffer.',
1279
- },
1280
- env: '$ctx.$env exposes a sanitized process env snapshot with exact sensitive keys removed: DB_URI, DB_REPLICA_URIS, REDIS_URI, SECRET_KEY, and ADMIN_PASSWORD. Store app secrets in unpublished isEncrypted fields instead of reading them from $env.',
1281
- },
1282
- contexts: {
1283
- preHook: {
1284
- runs: 'Before handler.',
1285
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@REPOS', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@THROW*', '@SOCKET global emit helpers/roomSize'],
1286
- queryContract: '@QUERY.filter is initialized as an object. When adding RLS/scope filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
1287
- projectionContract: 'For canonical table reads, preserve client-controlled query shape. Do not override @QUERY.fields, @QUERY.deep, @QUERY.sort, @QUERY.limit, @QUERY.page, @QUERY.meta, @QUERY.aggregate, or debugMode. RLS should only merge security constraints into @QUERY.filter.',
1288
- rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
1289
- returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
1290
- },
1291
- handler: {
1292
- runs: 'Main route logic, or canonical CRUD if no handler overrides.',
1293
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@RES when response streaming is available', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main secure route main table repo', '#table_name / @REPOS.<table> explicit table repo with trusted projection discipline', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@PKGS', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1294
- queryContract: 'When a handler wraps a canonical table read, pass through client fields/deep/sort/page/limit/meta/aggregate/debugMode unless the route is a clearly custom summary or workflow endpoint.',
1295
- returnBehavior: 'Return value becomes response body unless post-hook changes it.',
1296
- },
1297
- postHook: {
1298
- runs: 'After handler, including error path.',
1299
- data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@SHARE', '@API'],
1300
- returnBehavior: 'Mutate @DATA/$ctx.$data or return a non-undefined replacement response.',
1301
- },
1302
- flowStep: {
1303
- runs: 'Inside flow execution or admin flow step test.',
1304
- data: ['@BODY payload', '@USER if provided', '@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1305
- resultBehavior: 'Step return value is injected into @FLOW.<step.key> and @FLOW_LAST.',
1306
- branching: 'Condition steps use JavaScript truthy/falsy result; child branch is true/false.',
1307
- },
1308
- websocketConnection: {
1309
- runs: 'Socket.IO connection handler.',
1310
- data: ['@BODY connection info', '@DATA connection info', '@REQ websocket request metadata', '@API request metadata', '@USER if authenticated', '@HELPERS', '@FETCH', '@SOCKET reply/join/leave/disconnect/emit helpers/roomSize'],
1311
- },
1312
- websocketEvent: {
1313
- runs: 'Socket.IO event handler.',
1314
- data: ['@BODY event payload', '@DATA event payload', '@REQ websocket request metadata', '@API request metadata', '@USER if authenticated', '@HELPERS', '@FETCH', '@SOCKET reply/join/leave/disconnect/emit helpers/roomSize'],
1315
- resultBehavior: 'Client ack receives queued state first; handler result is emitted asynchronously as ws:result/ws:error with requestId.',
1316
- },
1317
- oauthUserProvisioning: {
1318
- runs: 'Before a new OAuth identity creates its enfyra_user row.',
1319
- data: ['@REPOS.main scoped to enfyra_user', '@HELPERS', '@FETCH', '@STORAGE', '@CACHE'],
1320
- resultBehavior: 'Return a plain object of additional user fields. Provider identity fields are merged afterward and take precedence. The script has no authenticated @USER and should not return a user response.',
1321
- },
1322
- graphqlResolver: {
1323
- runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
1324
- data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
1325
- caveat: 'REST publicMethods do not make GraphQL table data anonymous.',
1326
- },
1327
- extensionVueSfc: {
1328
- runs: 'Frontend extension code, not server sandbox.',
1329
- data: ['Vue/Nuxt composables', 'Enfyra composables', 'auto-resolved UI components'],
1330
- caveat: 'No import statements; save as enfyra_extension Vue SFC record.',
1331
- },
1332
- },
1333
- helpers: {
1334
- repos: {
1335
- scopes: '$repos.main is the secure repository for the route main table and preserves normal route query behavior. For explicit user-facing table access, use #secure.table_name or @REPOS.secure.table_name so field permissions remain enforced. Reserve #table_name or @REPOS.table_name for trusted internal operations that intentionally bypass field permissions.',
1336
- security: 'Trusted repos can bypass normal exposure boundaries, including unpublished columns and private relations. If trusted access is necessary, always request explicit fields, enforce route access plus owner/tenant/member checks, and project/sanitize the result before returning it.',
1337
- sensitiveQuerySurface: 'Filters, sort helpers, counts, and aggregate values on unpublished fields or private relations can leak information even when the value is not selected. Do not expose aggregate, _max, _min, _count, or predicate-oracle behavior over hidden fields in generated user-facing endpoints.',
1338
- mutationReturnShape: '$repos.<table>.create({ data }) and $repos.<table>.update({ id, data }) return a collection-shaped result: { data: [...], count? }. data is always an array for create/update, even for one created/updated record. If a script needs the single record object, it must read result.data[0] or result.data?.[0] ?? null.',
1339
- preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
1340
- wrongSingleRecordAccess: 'Do not use result.data.id, do not return result.data when one object is expected, and do not assume create/update returns the bare row object.',
1341
- countPattern: 'To count records in custom code, do not fetch full rows. Use const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); then read result.meta.filterCount or result.meta.totalCount.',
1342
- relationProjectionPattern: 'For repository find({ deep }) in scripts, include relation property names in top-level fields or the parent row will not expose row.<relation>. Example: await #orders.find({ fields: ["id", "customer"], deep: { customer: { fields: ["id", "email"] } }, limit: 1 }). query_table auto-adds this for MCP reads; dynamic repos do not.',
1343
- relationFilterPattern: 'Filter relations by relation propertyName, not physical FK names. Use { incident: { id: { _eq: incident.id } } }, not { incidentId: { _eq: incident.id } }.',
1344
- },
1345
- socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast and roomSize, but cannot reply/join/leave/disconnect/emitToCurrentRoom/broadcastToRoom because there is no bound socket. emitToRoom requires an explicit gateway path: emitToRoom(path, room, event, data). roomSize(room) counts sockets in that room across registered gateways.',
1346
- packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
1347
- files: 'Upload helpers are on $storage; raw create_records on enfyra_file is not equivalent to multipart upload/storage rollback. For multipart request files, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from disk-backed temp storage. For progress, clients send x-enfyra-upload-id on authenticated multipart requests and listen for $system:upload:progress; $upload and blob-replacing $update do not accept onProgress. Use @STORAGE.$registerFile only when the object already exists in storage and the script should create the enfyra_file record without uploading bytes. Use buffer only for small generated files.',
1348
- },
1349
- adminTesting: {
1350
- flowStep: 'Use test_flow_step or run_admin_test(kind=flow_step).',
1351
- websocket: 'Use run_admin_test(kind=websocket_event|websocket_connection).',
1352
- },
1353
- };
1354
- return jsonContent(payload);
1355
- });
1356
- // ============================================================================
1357
- // QUERY TOOLS
1358
- // ============================================================================
1359
- server.tool('get_enfyra_api_context', [
1360
- 'Returns the resolved API base URL for this MCP session (env ENFYRA_API_URL).',
1361
- 'Use this as the cheap first target sanity check before broad discovery or mutations.',
1362
- '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}).',
1363
- 'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
1364
- 'If path might differ from table name, use get_all_routes before asserting a URL.',
1365
- 'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_records=sequential POST /table, update_records=sequential PATCH /table/id, delete_records=sequential DELETE /table/id.',
1366
- 'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_tables graphqlEnabled and send Bearer auth for table data queries. Anonymous root/schema probes may still return 200.',
1367
- ].join(' '), {}, async () => {
1368
- const base = ENFYRA_API_URL.replace(/\/$/, '');
1369
- const gql = buildGraphqlUrls(ENFYRA_API_URL);
1370
- const payload = {
1371
- targetInstance: targetInstance(),
1372
- enfyraApiUrl: base,
1373
- graphqlHttpUrl: gql.graphqlHttpUrl,
1374
- graphqlSchemaUrl: gql.graphqlSchemaUrl,
1375
- examples: {
1376
- listOrCreate: `${base}/<table_name>`,
1377
- updateOrDelete: `${base}/<table_name>/<id>`,
1378
- oneRowById: `${base}/<table_name>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`,
1379
- },
1380
- auth: {
1381
- publicMethods: 'If the HTTP method is public for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
1382
- graphql: 'GraphQL table data requires Bearer auth; route publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
1383
- mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
1384
- },
1385
- pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
1386
- note: 'Full tool→HTTP mapping is in MCP server instructions (shown to the model at connect).',
1387
- };
1388
- return jsonContent(payload);
1389
- });
1390
- server.tool('query_table', 'Query any route-backed table with a live metadata preflight. Explicit fields are validated before the REST read and the result includes schemaReceipt, so a separate metadata call is optional unless the schema itself must be inspected. Response is minimal unless fields is explicit. Every call must pass either limit or all=true. OAuth clientId/clientSecret are write-only and cannot be read; ask the user and use setup_oauth_provider. Use count_records or meta=filterCount/totalCount for counts; call discover_query_capabilities before using aggregate objects instead of guessing _sum/_count operators. For enfyra_extension, editable extension source is `code`, not `sourceCode`; prefer search_admin_extensions and patch_extension_code/update_extension_code for admin UI.', {
1391
- tableName: z.string().describe('Table name to query'),
1392
- filter: jsonObjectParam(z, 'Filter object').optional().describe('Filter object. Example: {"status": {"_eq": "active"}}.'),
1393
- sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
1394
- page: z.number().optional().describe('Page number (default: 1)'),
1395
- limit: z.number().int().min(0).optional().describe('Items per page. Required unless all=true. Do not invent arbitrary limits for "all"; use all=true instead. Use count_records for counts.'),
1396
- all: z.boolean().optional().default(false).describe('Return all matching rows by sending REST limit=0. Use this when the user asks for all rows or a complete list.'),
1397
- fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
1398
- meta: z.string().optional().describe('Optional REST meta request, e.g. "totalCount", "filterCount", or aggregate modes supported by the route. Use count_records for simple counts.'),
1399
- deep: jsonObjectParam(z, 'Deep relation fetch object').optional().describe('Optional deep relation fetch object. Keys must be relation propertyName values.'),
1400
- aggregate: jsonObjectParam(z, 'Aggregate object').optional().describe('Optional aggregate object keyed by real fields/relations, only after discover_query_capabilities confirms the supported operator shape for this table/route. Results are returned in response.meta.aggregate when supported. Do not guess _sum/_count; use count_records or meta=filterCount/totalCount for counts. Do not request aggregates over hidden fields/private relations in user-facing APIs.'),
1401
- }, async ({ tableName, filter, sort, page, limit, all, fields, meta, deep, aggregate }) => {
1402
- if (!all && limit === undefined) {
1403
- throw new Error('query_table requires either limit or all=true. Do not rely on implicit default page sizes.');
1404
- }
1405
- if (all && limit !== undefined) {
1406
- throw new Error('query_table accepts either all=true or limit, not both.');
1407
- }
1408
- validateTableName(tableName);
1409
- assertExtensionReadFields(tableName, fields);
1410
- assertRecordFieldsReadable(tableName, fields);
1411
- const filterParam = stringifyJsonArg(filter);
1412
- const deepParam = stringifyJsonArg(deep);
1413
- const aggregateParam = stringifyJsonArg(aggregate);
1414
- validateFilter(filter);
1415
- parseJsonArg(deep, undefined);
1416
- parseJsonArg(aggregate, undefined);
1417
- const queryParams = new URLSearchParams();
1418
- const table = await getTableSummary(tableName);
1419
- const primaryKey = await getPrimaryFieldName(tableName, table);
1420
- const requestedFields = fields && fields.length > 0 ? fields : [primaryKey];
1421
- const deepFieldSelection = applyDeepFieldSelections(requestedFields, deep);
1422
- const selectedFields = deepFieldSelection.fields;
1423
- const schemaReceipt = buildQuerySchemaReceipt({ ...table, primaryKey }, selectedFields);
1424
- if (filterParam)
1425
- queryParams.set('filter', filterParam);
1426
- const normalizedSort = normalizeSortParam(sort);
1427
- if (normalizedSort)
1428
- queryParams.set('sort', normalizedSort);
1429
- if (page)
1430
- queryParams.set('page', String(page));
1431
- if (meta)
1432
- queryParams.set('meta', meta);
1433
- if (deepParam)
1434
- queryParams.set('deep', deepParam);
1435
- if (aggregateParam)
1436
- queryParams.set('aggregate', aggregateParam);
1437
- const effectiveLimit = all ? 0 : limit;
1438
- queryParams.set('limit', String(effectiveLimit));
1439
- queryParams.set('fields', selectedFields.join(','));
1440
- const query = queryParams.toString();
1441
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}${query ? `?${query}` : ''}`);
1442
- const payload = {
1443
- statusCode: result?.statusCode,
1444
- success: result?.success,
1445
- tableName,
1446
- requestedFields,
1447
- fields: selectedFields,
1448
- autoAddedDeepFields: deepFieldSelection.autoAdded,
1449
- limit: effectiveLimit,
1450
- all: !!all,
1451
- queryOptions: {
1452
- meta: meta || null,
1453
- deep: deep ? parseJsonArg(deep, null) : null,
1454
- aggregate: aggregate ? parseJsonArg(aggregate, null) : null,
1455
- },
1456
- minimalDefaultApplied: !(fields && fields.length > 0),
1457
- schemaReceipt,
1458
- meta: result?.meta,
1459
- data: compactSourceFields(result?.data || [], { tableName }),
1460
- detailHint: fields && fields.length > 0
1461
- ? undefined
1462
- : 'Only the primary key was returned because fields was omitted. Re-run query_table with explicit fields for details, or use inspect_table to find valid field names.',
1463
- };
1464
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1465
- });
1466
- server.tool('count_records', [
1467
- 'Count records in a route-backed Enfyra table using the lightweight REST meta pattern.',
1468
- 'Without filter it requests fields=id&limit=1&meta=totalCount and returns meta.totalCount.',
1469
- 'With filter it requests fields=id&limit=1&meta=filterCount and returns meta.filterCount.',
1470
- 'Use this instead of fetching rows when the user only needs a count.',
1471
- ].join(' '), {
1472
- tableName: z.string().describe('Table name to count. Must have a REST route.'),
1473
- filter: jsonObjectParam(z, 'Filter object').optional().describe('Optional Query DSL filter object. Example: {"status":{"_eq":"active"}}.'),
1474
- }, async ({ tableName, filter }) => {
1475
- validateTableName(tableName);
1476
- validateFilter(filter);
1477
- const filterParam = stringifyJsonArg(filter);
1478
- const metaField = filterParam ? 'filterCount' : 'totalCount';
1479
- const queryParams = new URLSearchParams();
1480
- queryParams.set('fields', 'id');
1481
- queryParams.set('limit', '1');
1482
- queryParams.set('meta', metaField);
1483
- if (filterParam)
1484
- queryParams.set('filter', filterParam);
1485
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${queryParams.toString()}`);
1486
- const meta = result?.meta || {};
1487
- const hasCount = Object.prototype.hasOwnProperty.call(meta, metaField);
1488
- const count = hasCount ? Number(meta[metaField]) : null;
1489
- const payload = {
1490
- tableName,
1491
- count,
1492
- countField: metaField,
1493
- filterApplied: !!filterParam,
1494
- meta,
1495
- request: {
1496
- path: `/${tableName}`,
1497
- query: Object.fromEntries(queryParams.entries()),
1498
- },
1499
- warning: hasCount ? undefined : `Response meta did not include ${metaField}.`,
1500
- };
1501
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1502
- });
1503
- server.tool('find_one_record', 'Find a single record by ID or filter. By ID uses GET with filter (Enfyra has no GET /table/:id route). OAuth clientId/clientSecret are write-only and cannot be read; ask the user and use setup_oauth_provider. For enfyra_extension, editable extension source is `code`, not `sourceCode`; prefer search_admin_extensions and patch_extension_code/update_extension_code for admin UI.', {
1504
- tableName: z.string().describe('Table name'),
1505
- id: z.string().optional().describe('Record ID'),
1506
- filter: jsonObjectParam(z, 'Filter object').optional().describe('Filter object to find by.'),
1507
- fields: z.array(z.string()).optional().describe('Fields to select. If omitted, returns only the primary key.'),
1508
- }, async ({ tableName, id, filter, fields }) => {
1509
- validateTableName(tableName);
1510
- assertExtensionReadFields(tableName, fields);
1511
- assertRecordFieldsReadable(tableName, fields);
1512
- const primaryKey = await getPrimaryFieldName(tableName);
1513
- const selectedFields = fields && fields.length > 0 ? fields : [primaryKey];
1514
- if (id) {
1515
- // Enfyra route engine does not register GET /<table>/:id (only PATCH/DELETE use /:id). Use list + filter.
1516
- const filterObj = JSON.stringify({ [primaryKey]: { _eq: id } });
1517
- const queryParams = new URLSearchParams({
1518
- filter: filterObj,
1519
- limit: '1',
1520
- fields: selectedFields.join(','),
1521
- });
1522
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${queryParams.toString()}`);
1523
- const one = result.data?.[0] ?? null;
1524
- return { content: [{ type: 'text', text: JSON.stringify({
1525
- tableName,
1526
- primaryKey,
1527
- fields: selectedFields,
1528
- data: compactSourceFields(one, { tableName }),
1529
- detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
1530
- }, null, 2) }] };
1531
- }
1532
- if (!filter)
1533
- throw new Error('Provide id or filter');
1534
- validateFilter(filter);
1535
- const filterParam = stringifyJsonArg(filter);
1536
- const queryParams = new URLSearchParams({
1537
- filter: filterParam || '',
1538
- limit: '1',
1539
- fields: selectedFields.join(','),
1540
- });
1541
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${queryParams.toString()}`);
1542
- return { content: [{ type: 'text', text: JSON.stringify({
1543
- tableName,
1544
- fields: selectedFields,
1545
- data: compactSourceFields(result.data?.[0] || null, { tableName }),
1546
- detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
1547
- }, null, 2) }] };
1548
- });
1549
- // ============================================================================
1550
- // CRUD TOOLS
1551
- // ============================================================================
1552
- server.tool('create_records', 'Create one or more route-backed records. Always pass records as a native JSON array; for one record, pass a one-item array. MCP preflights every item before the first POST, then writes sequentially; this is not a backend bulk endpoint or transaction. On a failed item, it returns the completed checkpoint and remaining indexes—retry only the remaining records after resolving the error.', {
1553
- tableName: z.string().describe('Table name to insert into'),
1554
- records: bulkObjectArrayParam(z, 'Records').describe('Records as a native JSON array. Each item must be a JSON object using metadata-backed column names and relation propertyName values.'),
1555
- queryParams: z.string().optional().describe('Optional query params as JSON object string applied to every POST, for route contracts that intentionally keep workflow fields out of the validated body.'),
1556
- maxRecords: z.number().int().min(1).max(100).optional().default(20).describe('Safety cap for one MCP batch. Default is 20; explicitly raise it up to 100 only when partial-write recovery is acceptable.'),
1557
- globalRulesAckKey: globalRulesAckParam(z),
1558
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when any item contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1559
- extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and any item contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1560
- }, async ({ tableName, records, queryParams, maxRecords, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1561
- assertGlobalRulesAck(globalRulesAckKey);
1562
- validateTableName(tableName);
1563
- assertGenericRecordMutationAllowed('create', tableName);
1564
- const parsedRecords = parseRecordBatchData(records);
1565
- if (parsedRecords.length > maxRecords) {
1566
- throw new Error(`create_records received ${parsedRecords.length} records, above maxRecords=${maxRecords}. Split the batch deliberately.`);
1567
- }
1568
- assertKnowledgeForGenericBatchMutation(tableName, parsedRecords, { knowledgeAckKey, extensionKnowledgeAckKey });
1569
- const prepared = await prepareGenericBatchMutation(tableName, parsedRecords);
1570
- const extensionValidations = [];
1571
- for (const item of prepared.records) {
1572
- extensionValidations.push(await validateExtensionCodeForGenericMutation(tableName, item.payload, item.payload?.name || item.index));
1573
- }
1574
- const query = parseQueryParamsArg(queryParams);
1575
- const batch = await executeSequentialBatch(prepared.records, async (item) => {
1576
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(item.payload) });
1577
- return {
1578
- index: item.index,
1579
- ...summarizeMutationResult(result, 'created', tableName),
1580
- };
1581
- });
1582
- if (batch.status === 'partial_failure') {
1583
- return {
1584
- isError: true,
1585
- content: [{ type: 'text', text: JSON.stringify({
1586
- action: 'create_records_partial_failure',
1587
- tableName,
1588
- requested: parsedRecords.length,
1589
- createdCount: batch.completed.length,
1590
- sequential: true,
1591
- transactional: false,
1592
- completed: batch.completed,
1593
- failed: batch.failure,
1594
- remainingIndexes: batch.remainingIndexes,
1595
- retryHint: 'Resolve the failed item, then retry only the failed item and remaining indexes. Do not retry completed records unless the table has an idempotent unique key.',
1596
- }, null, 2) }],
1597
- };
1598
- }
1599
- return { content: [{ type: 'text', text: JSON.stringify({
1600
- action: 'created_records',
1601
- tableName,
1602
- requested: parsedRecords.length,
1603
- createdCount: batch.completed.length,
1604
- sequential: true,
1605
- transactional: false,
1606
- preflight: {
1607
- liveMetadataFieldsValidated: true,
1608
- scriptValidatedBeforeAnyPost: prepared.records.some((item) => item.scriptValidation?.validated === true),
1609
- extensionValidatedBeforeAnyPost: extensionValidations.some(Boolean),
1610
- },
1611
- created: batch.completed,
1612
- detailHint: `Use query_table({ tableName: "${tableName}", fields: [...], limit: ${Math.min(batch.completed.length, 20)} }) to inspect created records when needed.`,
1613
- }, null, 2) }] };
1614
- });
1615
- server.tool('update_records', 'Update one or more records in one MCP call. Pass items as a native JSON array; for one update, pass one item. MCP preflights every item, rejects duplicate ids, then PATCHes sequentially. On a failed item, it returns the completed checkpoint and remaining indexes so callers do not replay prior updates.', {
1616
- tableName: z.string().describe('Table name'),
1617
- items: bulkObjectArrayParam(z, 'Update items').describe('Native JSON array of update items: [{ "id": "...", "data": { ... }, "queryParams": { ... }? }]. data must use metadata-backed column names and relation propertyName values.'),
1618
- maxItems: z.number().int().min(1).max(100).optional().default(20).describe('Safety cap for one MCP batch. Default is 20; explicitly raise it up to 100 only when partial-write recovery is acceptable.'),
1619
- globalRulesAckKey: globalRulesAckParam(z),
1620
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when any item.data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1621
- extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and any item.data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1622
- }, async ({ tableName, items, maxItems, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1623
- assertGlobalRulesAck(globalRulesAckKey);
1624
- validateTableName(tableName);
1625
- assertGenericRecordMutationAllowed('update', tableName);
1626
- const parsedItems = parseBulkItemsArg('items', items);
1627
- assertMaxBulkItems('update_records', parsedItems, maxItems);
1628
- assertNoDuplicateBulkIds('update_records', parsedItems);
1629
- const preparedItems = [];
1630
- const extensionValidations = [];
1631
- for (const [index, item] of parsedItems.entries()) {
1632
- if (!item.id)
1633
- throw new Error(`items[${index}].id is required.`);
1634
- if (!item.data || typeof item.data !== 'object' || Array.isArray(item.data)) {
1635
- throw new Error(`items[${index}].data must be a JSON object.`);
1636
- }
1637
- assertKnowledgeForGenericMutation(tableName, JSON.stringify(item.data), { knowledgeAckKey, extensionKnowledgeAckKey });
1638
- const prepared = await prepareGenericMutation(tableName, JSON.stringify(item.data));
1639
- preparedItems.push({ index, id: item.id, queryParams: item.queryParams, prepared });
1640
- extensionValidations.push(await validateExtensionCodeForGenericMutation(tableName, prepared.payload, item.id));
1641
- }
1642
- const batch = await executeSequentialBatch(preparedItems, async (item) => {
1643
- const query = parseQueryParamsArg(JSON.stringify(item.queryParams || {}));
1644
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${encodeURIComponent(String(item.id))}`, query), { method: 'PATCH', body: JSON.stringify(item.prepared.payload) });
1645
- return {
1646
- index: item.index,
1647
- id: item.id,
1648
- ...summarizeMutationResult(result, 'updated', tableName),
1649
- };
1650
- });
1651
- if (batch.status === 'partial_failure') {
1652
- return {
1653
- isError: true,
1654
- content: [{ type: 'text', text: JSON.stringify({
1655
- action: 'update_records_partial_failure',
1656
- tableName,
1657
- requested: parsedItems.length,
1658
- updatedCount: batch.completed.length,
1659
- sequential: true,
1660
- completed: batch.completed,
1661
- failed: batch.failure,
1662
- remainingIndexes: batch.remainingIndexes,
1663
- retryHint: 'Resolve the failed item, then retry only the failed item and remaining indexes. Do not replay completed updates unless the new value is deliberately idempotent.',
1664
- }, null, 2) }],
1665
- };
1666
- }
1667
- return { content: [{ type: 'text', text: JSON.stringify({
1668
- action: 'updated_records',
1669
- tableName,
1670
- requested: parsedItems.length,
1671
- updatedCount: batch.completed.length,
1672
- sequential: true,
1673
- duplicateIdsRejected: true,
1674
- preflight: {
1675
- liveMetadataFieldsValidated: true,
1676
- scriptValidatedBeforeAnyPatch: preparedItems.some((item) => item.prepared.scriptValidation?.validated === true),
1677
- extensionValidatedBeforeAnyPatch: extensionValidations.some(Boolean),
1678
- },
1679
- updated: batch.completed,
1680
- }, null, 2) }] };
1681
- });
1682
- server.tool('get_script_source', [
1683
- 'Fetch the full editable source for one script-backed metadata record without preview truncation.',
1684
- 'Use search_runtime_zone first and pass the returned nextInspect.input to inspect the concrete record. The inspection already returns exact source artifacts.',
1685
- 'Call get_script_source only when a fresh artifact is needed for that located record. Never guess or probe record ids.',
1686
- ].join(' '), {
1687
- tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to read'),
1688
- id: z.string().describe('Concrete record id returned by search_runtime_zone, inspect output, or a successful create/update operation. Never guess an id.'),
1689
- }, async ({ tableName, id }) => {
1690
- const { primaryKey, record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1691
- const sourceArtifact = writeSourceArtifact({ tableName, id, fieldName: sourceField, source: sourceCode });
1692
- return { content: [{ type: 'text', text: JSON.stringify({
1693
- tableName,
1694
- id,
1695
- primaryKey,
1696
- sourceField,
1697
- sourceFile: sourceArtifact.tmpFile,
1698
- sourcePreview: sourceArtifact.preview,
1699
- sourceLength: sourceCode.length,
1700
- sourceSha256: sha256(sourceCode),
1701
- scriptLanguage: record.scriptLanguage || record.language || null,
1702
- record: scriptRecordLabel(tableName, record),
1703
- }, null, 2) }] };
1704
- });
1705
- server.tool('patch_script_source', [
1706
- 'Patch sourceCode on a script-backed record using exact search/replace with optional hash checking.',
1707
- 'By default this returns a preview only. Set apply=true to validate through /admin/script/validate and save.',
1708
- 'Use get_script_source first for long scripts, then patch only the exact block you intend to change.',
1709
- ].join(' '), {
1710
- tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to patch'),
1711
- id: z.string().describe('Record ID to patch'),
1712
- oldText: z.string().describe('Exact text to replace'),
1713
- newText: z.string().describe('Replacement text'),
1714
- occurrence: z.enum(['first', 'all']).optional().default('all').describe('Replace first occurrence or all occurrences.'),
1715
- expectedSourceSha256: z.string().optional().describe('Optional SHA-256 from get_script_source; fails if source changed.'),
1716
- scriptLanguage: z.string().optional().describe('Script language to save. Defaults to existing scriptLanguage or javascript.'),
1717
- apply: z.boolean().optional().default(false).describe('false returns preview only; true validates and saves.'),
1718
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1719
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply=true. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1720
- }, async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply, globalRulesAckKey, knowledgeAckKey }) => {
1721
- const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1722
- if (sourceField !== 'sourceCode') {
1723
- throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_records intentionally for this legacy field.`);
1724
- }
1725
- const beforeHash = sha256(sourceCode);
1726
- if (expectedSourceSha256 && expectedSourceSha256 !== beforeHash) {
1727
- throw new Error(`Source hash mismatch. Current sha256 is ${beforeHash}; re-read with get_script_source before patching.`);
1728
- }
1729
- const { occurrences, patched, replaced } = replaceOccurrence(sourceCode, oldText, newText, occurrence || 'all');
1730
- const afterHash = sha256(patched);
1731
- const payload = {
1732
- action: apply ? 'patch_script_source_applied' : 'patch_script_source_preview',
1733
- tableName,
1734
- id,
1735
- sourceField,
1736
- sourceLengthBefore: sourceCode.length,
1737
- sourceLengthAfter: patched.length,
1738
- sourceSha256Before: beforeHash,
1739
- sourceSha256After: afterHash,
1740
- occurrences,
1741
- replaced,
1742
- preview: {
1743
- before: sourcePreview(sourceCode, oldText),
1744
- after: sourcePreview(patched, newText),
1745
- },
1746
- next: apply ? undefined : 'Call patch_script_source again with apply=true and expectedSourceSha256 set to sourceSha256Before to validate and save.',
1747
- };
1748
- if (!apply) {
1749
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1750
- }
1751
- assertGlobalRulesAck(globalRulesAckKey);
1752
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1753
- const language = scriptLanguage || record.scriptLanguage || 'javascript';
1754
- const prepared = await prepareGenericMutation(tableName, JSON.stringify({ sourceCode: patched, scriptLanguage: language }));
1755
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${encodeURIComponent(String(id))}`, { method: 'PATCH', body: JSON.stringify(prepared.payload) });
1756
- return { content: [{ type: 'text', text: JSON.stringify({
1757
- ...payload,
1758
- ...summarizeMutationResult(result, 'patch_script_source_applied', tableName),
1759
- id,
1760
- scriptLanguage: language,
1761
- scriptValidation: prepared.scriptValidation,
1762
- }, null, 2) }] };
1763
- });
1764
- server.tool('update_script_source', [
1765
- 'Update sourceCode on a script-backed record without forcing the caller to JSON-escape long code.',
1766
- 'Use this for enfyra_flow_step, enfyra_route_handler, enfyra_pre_hook, enfyra_post_hook, enfyra_websocket_event, enfyra_websocket, enfyra_oauth_config, and enfyra_bootstrap_script.',
1767
- 'The tool validates sourceCode through /admin/script/validate before saving and never accepts compiledCode.',
1768
- ].join(' '), {
1769
- tableName: z.enum([
1770
- 'enfyra_route_handler',
1771
- 'enfyra_pre_hook',
1772
- 'enfyra_post_hook',
1773
- 'enfyra_flow_step',
1774
- 'enfyra_websocket_event',
1775
- 'enfyra_websocket',
1776
- 'enfyra_oauth_config',
1777
- 'enfyra_bootstrap_script',
1778
- ]).describe('Script-backed table to update'),
1779
- id: z.string().describe('Record ID to update'),
1780
- sourceCode: z.string().describe('Editable script sourceCode. Pass the raw code string; do not JSON-escape it yourself.'),
1781
- scriptLanguage: z.string().optional().default('javascript').describe('Script language, usually javascript or typescript'),
1782
- globalRulesAckKey: globalRulesAckParam(z),
1783
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1784
- }, async ({ tableName, id, sourceCode, scriptLanguage, globalRulesAckKey, knowledgeAckKey }) => {
1785
- assertGlobalRulesAck(globalRulesAckKey);
1786
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1787
- validateTableName(tableName);
1788
- const prepared = await prepareGenericMutation(tableName, JSON.stringify({ sourceCode, scriptLanguage }));
1789
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}/${encodeURIComponent(String(id))}`, { method: 'PATCH', body: JSON.stringify(prepared.payload) });
1790
- return { content: [{ type: 'text', text: JSON.stringify({
1791
- ...summarizeMutationResult(result, 'updated_script_source', tableName),
1792
- id,
1793
- sourceLength: sourceCode.length,
1794
- scriptLanguage,
1795
- scriptValidation: prepared.scriptValidation,
1796
- }, null, 2) }] };
1797
- });
1798
- function isNotFoundDeleteError(error) {
1799
- const message = String(error?.message || error || '').toLowerCase();
1800
- return message.includes('api error (404)')
1801
- || message.includes('not found')
1802
- || message.includes('not exists')
1803
- || message.includes('does not exist');
1804
- }
1805
- server.tool('delete_records', 'Delete one or more route-backed records in one MCP call. Pass items as a native JSON array; for one delete, pass one item. The tool previews every target when confirm=false, rejects duplicate ids, and deletes sequentially when confirm=true. Confirmed deletes automatically re-read the requested primary keys and return postcondition.confirmedAbsent plus remainingIds, so a separate absence query is optional. By default, confirm=true skips records that were already removed by cascade or a previous cleanup step.', {
1806
- tableName: z.string().describe('Table name'),
1807
- items: bulkObjectArrayParam(z, 'Delete items').describe('Native JSON array of delete items: [{ "id": "...", "queryParams": { ... }? }].'),
1808
- maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one MCP batch. Default/max is 100.'),
1809
- confirm: z.boolean().optional().default(false).describe('Required true to apply destructive deletes. Omit/false returns previews only.'),
1810
- skipNotFound: z.boolean().optional().default(true).describe('When confirm=true, continue if a target is already gone, for example because a previous delete cascaded child records. Default true.'),
1811
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1812
- }, async ({ tableName, items, maxItems, confirm, skipNotFound, globalRulesAckKey }) => {
1813
- validateTableName(tableName);
1814
- assertGenericRecordMutationAllowed('delete', tableName);
1815
- const parsedItems = parseBulkItemsArg('items', items);
1816
- assertMaxBulkItems('delete_records', parsedItems, maxItems);
1817
- assertNoDuplicateBulkIds('delete_records', parsedItems);
1818
- for (const [index, item] of parsedItems.entries()) {
1819
- if (!item.id)
1820
- throw new Error(`items[${index}].id is required.`);
1821
- }
1822
- const primaryKey = await getPrimaryFieldName(tableName);
1823
- if (!confirm) {
1824
- const previews = [];
1825
- for (const [index, item] of parsedItems.entries()) {
1826
- const query = new URLSearchParams({
1827
- filter: JSON.stringify({ [primaryKey]: { _eq: item.id } }),
1828
- limit: '1',
1829
- fields: primaryKey,
1830
- });
1831
- const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
1832
- previews.push({
1833
- index,
1834
- id: item.id,
1835
- preview: preview?.data?.[0] || null,
1836
- previewError: preview?.error,
1837
- });
1838
- }
1839
- return { content: [{ type: 'text', text: JSON.stringify({
1840
- action: 'delete_records_preview',
1841
- tableName,
1842
- primaryKey,
1843
- requested: parsedItems.length,
1844
- duplicateIdsRejected: true,
1845
- destructive: true,
1846
- previews,
1847
- postcondition: {
1848
- verificationMethod: 'not_run_preview',
1849
- requestedIds: parsedItems.map((item) => item.id),
1850
- remainingIds: previews.filter((item) => item.preview).map((item) => item.id),
1851
- confirmedAbsent: false,
1852
- },
1853
- next: 'Call delete_records again with the same items and confirm=true to delete these route-backed records sequentially.',
1854
- }, null, 2) }] };
1855
- }
1856
- assertGlobalRulesAck(globalRulesAckKey);
1857
- const deleted = [];
1858
- const skippedNotFound = [];
1859
- for (const [index, item] of parsedItems.entries()) {
1860
- const query = parseQueryParamsArg(JSON.stringify(item.queryParams || {}));
1861
- try {
1862
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${encodeURIComponent(String(item.id))}`, query), { method: 'DELETE' });
1863
- deleted.push({
1864
- index,
1865
- id: item.id,
1866
- statusCode: result?.statusCode,
1867
- success: result?.success,
1868
- });
1869
- }
1870
- catch (error) {
1871
- if (skipNotFound && isNotFoundDeleteError(error)) {
1872
- skippedNotFound.push({
1873
- index,
1874
- id: item.id,
1875
- skipped: true,
1876
- reason: 'not_found',
1877
- });
1878
- continue;
1879
- }
1880
- throw error;
1881
- }
1882
- }
1883
- const requestedIds = parsedItems.map((item) => item.id);
1884
- let postcondition;
1885
- try {
1886
- const verificationQuery = new URLSearchParams({
1887
- filter: JSON.stringify({ [primaryKey]: { _in: requestedIds } }),
1888
- limit: String(parsedItems.length),
1889
- fields: primaryKey,
1890
- });
1891
- const verification = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${verificationQuery.toString()}`);
1892
- postcondition = buildDeletePostcondition(requestedIds, verification?.data ?? [], primaryKey);
1893
- }
1894
- catch (error) {
1895
- postcondition = {
1896
- verificationMethod: 'route_read_by_primary_keys',
1897
- requestedIds,
1898
- remainingIds: [],
1899
- confirmedAbsent: false,
1900
- verificationError: String(error?.message || error),
1901
- };
1902
- }
1903
- return { content: [{ type: 'text', text: JSON.stringify({
1904
- action: 'deleted_records',
1905
- tableName,
1906
- requested: parsedItems.length,
1907
- deletedCount: deleted.length,
1908
- skippedNotFoundCount: skippedNotFound.length,
1909
- sequential: true,
1910
- duplicateIdsRejected: true,
1911
- skipNotFound,
1912
- deleted,
1913
- skippedNotFound,
1914
- postcondition,
1915
- }, null, 2) }] };
1916
- });
1917
- server.tool('list_methods', 'List enfyra_method records with their UI colors. Use this before creating route methods or method-colored UI.', {}, async () => {
1918
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_method?fields=id,_id,name,buttonColor,textColor,isSystem&sort=name&limit=0');
1919
- const methods = unwrapData(result).map((method) => ({
1920
- id: getId(method),
1921
- name: method.name,
1922
- buttonColor: method.buttonColor,
1923
- textColor: method.textColor,
1924
- isSystem: method.isSystem === true,
1925
- }));
1926
- return { content: [{ type: 'text', text: JSON.stringify({
1927
- tableName: 'enfyra_method',
1928
- methods,
1929
- appUi: '/settings/methods',
1930
- }, null, 2) }] };
1931
- });
1932
- server.tool('create_method', 'Create a enfyra_method record with app badge colors. Prefer this over generic create_records for enfyra_method.', {
1933
- method: z.string().describe('Uppercase method name, e.g. GET, POST, PUT, CUSTOM_METHOD. Must start with A-Z and contain only A-Z, 0-9, or underscore.'),
1934
- buttonColor: z.string().describe('Badge background color as full hex, e.g. #dbeafe.'),
1935
- textColor: z.string().describe('Badge text color as full hex, e.g. #1d4ed8.'),
1936
- isSystem: z.boolean().optional().default(false).describe('Set true only for built-in/runtime-owned methods. Normal app methods should leave this false.'),
1937
- globalRulesAckKey: globalRulesAckParam(z),
1938
- }, async ({ method, buttonColor, textColor, isSystem, globalRulesAckKey }) => {
1939
- assertGlobalRulesAck(globalRulesAckKey);
1940
- const normalizedMethod = normalizeMethodNameInput(method);
1941
- const existing = await findMethodRecordByName(normalizedMethod);
1942
- if (existing) {
1943
- throw new Error(`Method ${normalizedMethod} already exists with id ${getId(existing)}. Use update_method to change colors.`);
1944
- }
1945
- const body = {
1946
- name: normalizedMethod,
1947
- buttonColor: normalizeHexColorInput(buttonColor, 'buttonColor'),
1948
- textColor: normalizeHexColorInput(textColor, 'textColor'),
1949
- isSystem: isSystem === true,
1950
- };
1951
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_method', {
1952
- method: 'POST',
1953
- body: JSON.stringify(body),
1954
- });
1955
- _methodMap = null;
1956
- return { content: [{ type: 'text', text: JSON.stringify({
1957
- ...summarizeMutationResult(result, 'created', 'enfyra_method'),
1958
- name: normalizedMethod,
1959
- appUi: '/settings/methods',
1960
- }, null, 2) }] };
1961
- });
1962
- server.tool('update_method', 'Update a enfyra_method record color pair, and optionally rename non-system methods. Prefer this over generic update_records for enfyra_method.', {
1963
- id: z.string().optional().describe('Method record id. If omitted, method is used to find the record.'),
1964
- method: z.string().optional().describe('Existing method name to find, or new name when id is provided.'),
1965
- buttonColor: z.string().optional().describe('Badge background color as full hex, e.g. #dbeafe.'),
1966
- textColor: z.string().optional().describe('Badge text color as full hex, e.g. #1d4ed8.'),
1967
- globalRulesAckKey: globalRulesAckParam(z),
1968
- }, async ({ id, method, buttonColor, textColor, globalRulesAckKey }) => {
1969
- assertGlobalRulesAck(globalRulesAckKey);
1970
- let targetId = id;
1971
- let existing = null;
1972
- if (!targetId) {
1973
- if (!method)
1974
- throw new Error('Provide id or method.');
1975
- const normalizedMethod = normalizeMethodNameInput(method);
1976
- existing = await findMethodRecordByName(normalizedMethod);
1977
- if (!existing)
1978
- throw new Error(`Method ${normalizedMethod} was not found.`);
1979
- targetId = getId(existing);
1980
- }
1981
- const body = {};
1982
- if (buttonColor !== undefined) {
1983
- body.buttonColor = normalizeHexColorInput(buttonColor, 'buttonColor');
1984
- }
1985
- if (textColor !== undefined) {
1986
- body.textColor = normalizeHexColorInput(textColor, 'textColor');
1987
- }
1988
- if (method !== undefined && id) {
1989
- body.name = normalizeMethodNameInput(method);
1990
- }
1991
- if (Object.keys(body).length === 0) {
1992
- throw new Error('Provide buttonColor, textColor, or a new method name.');
1993
- }
1994
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method/${encodeURIComponent(String(targetId))}`, {
1995
- method: 'PATCH',
1996
- body: JSON.stringify(body),
1997
- });
1998
- _methodMap = null;
1999
- return { content: [{ type: 'text', text: JSON.stringify({
2000
- ...summarizeMutationResult(result, 'updated', 'enfyra_method'),
2001
- id: targetId,
2002
- appUi: '/settings/methods',
2003
- }, null, 2) }] };
2004
- });
2005
- server.tool('delete_method', 'Preview or delete a enfyra_method record. Only delete unused custom methods; system/default methods should be kept.', {
2006
- id: z.string().optional().describe('Method record id. If omitted, method is used to find the record.'),
2007
- method: z.string().optional().describe('Method name to find when id is omitted.'),
2008
- confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
2009
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
2010
- }, async ({ id, method, confirm, globalRulesAckKey }) => {
2011
- let targetId = id;
2012
- let target = null;
2013
- if (!targetId) {
2014
- if (!method)
2015
- throw new Error('Provide id or method.');
2016
- target = await findMethodRecordByName(normalizeMethodNameInput(method));
2017
- if (!target)
2018
- throw new Error(`Method ${method} was not found.`);
2019
- targetId = getId(target);
2020
- }
2021
- if (!confirm) {
2022
- if (!target) {
2023
- const primaryKey = await getPrimaryFieldName('enfyra_method');
2024
- const filter = encodeURIComponent(JSON.stringify({ [primaryKey]: { _eq: targetId } }));
2025
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
2026
- target = unwrapData(result)[0] || null;
2027
- }
2028
- return { content: [{ type: 'text', text: JSON.stringify({
2029
- action: 'delete_method_preview',
2030
- id: targetId,
2031
- name: target?.name,
2032
- isSystem: target?.isSystem === true,
2033
- destructive: true,
2034
- warning: 'Only delete unused custom methods. Deleting a method can affect route method relations.',
2035
- next: 'Call delete_method again with confirm=true to delete.',
2036
- }, null, 2) }] };
2037
- }
2038
- assertGlobalRulesAck(globalRulesAckKey);
2039
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method/${encodeURIComponent(String(targetId))}`, { method: 'DELETE' });
2040
- _methodMap = null;
2041
- return { content: [{ type: 'text', text: JSON.stringify({
2042
- action: 'deleted',
2043
- tableName: 'enfyra_method',
2044
- id: targetId,
2045
- statusCode: result?.statusCode,
2046
- success: result?.success,
2047
- }, null, 2) }] };
2048
- });
2049
- server.tool('run_admin_test', [
2050
- 'Run an Enfyra admin test without saving metadata. Wraps POST /admin/test/run.',
2051
- 'Kinds: script, flow_step, websocket_event, websocket_connection. Use this to validate dynamic script, flow, or websocket behavior before creating records.',
2052
- 'kind=script captures logs but not socket emitted calls. Use kind=websocket_event or kind=websocket_connection when emitted capture is required; admin websocket tests still do not prove a real Socket.IO client transport/handshake.',
2053
- ].join(' '), {
2054
- kind: z.enum(['script', 'flow_step', 'websocket_event', 'websocket_connection']).describe('Admin test kind'),
2055
- body: z.union([z.record(z.any()), z.string()]).describe('Test body as a native JSON object. A JSON string is accepted for compatibility. Include script and optional context for script; type/config plus payload for flow_step; or script/gatewayPath/eventName/payload for websocket tests. Do not include kind; the tool adds it.'),
2056
- }, async ({ kind, body }) => {
2057
- const parsed = parseJsonObjectInput(body, 'body');
2058
- const sourceCode = kind === 'flow_step'
2059
- ? parsed?.config?.sourceCode ?? parsed?.config?.code
2060
- : parsed?.script ?? parsed?.sourceCode;
2061
- if (typeof sourceCode === 'string')
2062
- validatePortableScriptSource(sourceCode);
2063
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
2064
- method: 'POST',
2065
- body: JSON.stringify({ ...parsed, kind }),
2066
- });
2067
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2068
- });
2069
- server.tool('test_flow_step', 'Test a single flow step without saving it. Wraps POST /admin/test/run with kind=flow_step. Pass runtime @FLOW_PAYLOAD data through payload; the tool forwards it using the ESV test-run contract.', {
2070
- type: z.enum(['script', 'condition', 'query', 'create', 'update', 'delete', 'http', 'trigger_flow', 'sleep', 'log']).describe('Flow step type'),
2071
- config: z.union([z.record(z.any()), z.string()]).describe('Step config as a native JSON object. A JSON string is accepted for compatibility.'),
2072
- timeout: z.number().optional().describe('Timeout in ms'),
2073
- key: z.string().optional().describe('Optional step key for mock flow context'),
2074
- payload: z.union([z.record(z.any()), z.string()]).optional().describe('Runtime payload object exposed to the script as @FLOW_PAYLOAD. A JSON object string is accepted for compatibility.'),
2075
- mockFlow: z.union([z.record(z.any()), z.string()]).optional().describe('Optional advanced mockFlow object for $last/$meta or other flow context. A JSON string is accepted for compatibility. Use payload for @FLOW_PAYLOAD.'),
2076
- }, async ({ type, config, timeout, key, payload, mockFlow }) => {
2077
- const parsedConfig = parseJsonObjectInput(config, 'config');
2078
- const sourceCode = parsedConfig?.sourceCode ?? parsedConfig?.code;
2079
- if (typeof sourceCode === 'string')
2080
- validatePortableScriptSource(sourceCode);
2081
- const parsedPayload = typeof payload === 'string' ? JSON.parse(payload) : payload;
2082
- if (parsedPayload !== undefined && (!parsedPayload || typeof parsedPayload !== 'object' || Array.isArray(parsedPayload))) {
2083
- throw new Error('payload must be a JSON object.');
2084
- }
2085
- const body = {
2086
- type,
2087
- config: parsedConfig,
2088
- ...(timeout ? { timeout } : {}),
2089
- ...(key ? { key } : {}),
2090
- ...(parsedPayload !== undefined ? { payload: parsedPayload } : {}),
2091
- ...(mockFlow ? { mockFlow: parseJsonObjectInput(mockFlow, 'mockFlow') } : {}),
2092
- };
2093
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
2094
- method: 'POST',
2095
- body: JSON.stringify({ ...body, kind: 'flow_step' }),
2096
- });
2097
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2098
- });
2099
- server.tool('trigger_flow', 'Trigger an enabled saved flow by id or name. Disabled flows are not registered for execution; use test_flow_step to verify their step contract without enabling them.', {
2100
- flowIdOrName: z.union([z.string(), z.number()]).describe('Flow id or name accepted by FlowService.trigger'),
2101
- payload: z.union([z.record(z.any()), z.string()]).optional().describe('Payload as a native JSON object. A JSON string is accepted for compatibility. Default {}.'),
2102
- }, async ({ flowIdOrName, payload }) => {
2103
- const rawIdentifier = String(flowIdOrName);
2104
- const filter = typeof flowIdOrName === 'number' || /^\d+$/.test(rawIdentifier)
2105
- ? { id: { _eq: flowIdOrName } }
2106
- : { name: { _eq: rawIdentifier } };
2107
- const lookup = await fetchAPI(ENFYRA_API_URL, `/enfyra_flow?filter=${encodeURIComponent(JSON.stringify(filter))}&limit=1&fields=id,_id,name,isEnabled`);
2108
- const flow = unwrapData(lookup)[0];
2109
- if (!flow)
2110
- throw new Error(`Flow not found: ${rawIdentifier}`);
2111
- if (flow.isEnabled === false) {
2112
- throw new Error(`Flow "${flow.name || rawIdentifier}" is disabled and is not registered for execution. Use test_flow_step to verify its saved step contract, or explicitly enable the flow before trigger_flow.`);
2113
- }
2114
- const flowId = flow.id ?? flow._id;
2115
- const result = await fetchAPI(ENFYRA_API_URL, `/admin/flow/trigger/${encodeURIComponent(String(flowId))}`, {
2116
- method: 'POST',
2117
- body: JSON.stringify({
2118
- payload: payload ? parseJsonObjectInput(payload, 'payload') : {},
2119
- }),
2120
- });
2121
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2122
- });
2123
- // ============================================================================
2124
- // ROUTE & HANDLER TOOLS
2125
- // ============================================================================
2126
- let _methodMap = null;
2127
- async function getMethodMap() {
2128
- if (_methodMap)
2129
- return _methodMap;
2130
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_method?limit=0');
2131
- _methodMap = {};
2132
- for (const m of result.data) {
2133
- _methodMap[m.name] = m.id || m._id;
2134
- }
2135
- return _methodMap;
2136
- }
2137
- function resolveMethodIds(methodMap, names) {
2138
- return names.map(m => {
2139
- const id = methodMap[m.toUpperCase()];
2140
- if (!id)
2141
- throw new Error(`Unknown method "${m}". Valid: ${Object.keys(methodMap).join(', ')}`);
2142
- return { id };
2143
- });
2144
- }
2145
- async function getMethodIdNameMap() {
2146
- const methodMap = await getMethodMap();
2147
- return Object.fromEntries(Object.entries(methodMap).map(([method, id]) => [String(id), method]));
2148
- }
2149
- function withMethodNames(records, methodIdNameMap, field = 'methods') {
2150
- return records.map((record) => ({
2151
- ...record,
2152
- [field]: Array.isArray(record?.[field])
2153
- ? record[field].map((item) => ({
2154
- ...item,
2155
- name: item.name || methodIdNameMap[String(getId(item))] || null,
2156
- }))
2157
- : record?.[field],
2158
- }));
2159
- }
2160
- async function collectRestDefinitionState(tableRef) {
2161
- await getValidToken(ENFYRA_API_URL);
2162
- const [metadataContext, routes, handlers, preHooks, postHooks, routePermissions, guards, guardRules, fieldPermissions, columnRules, methodIdNameMap,] = await Promise.all([
2163
- getMetadataTables(tableRef),
2164
- fetchAll('/enfyra_route?limit=1000'),
2165
- fetchAll('/enfyra_route_handler?limit=1000'),
2166
- fetchAll('/enfyra_pre_hook?limit=1000'),
2167
- fetchAll('/enfyra_post_hook?limit=1000'),
2168
- fetchAll('/enfyra_route_permission?limit=1000'),
2169
- fetchAll('/enfyra_guard?limit=1000'),
2170
- fetchAll('/enfyra_guard_rule?limit=1000'),
2171
- fetchAll('/enfyra_field_permission?limit=1000'),
2172
- fetchAll('/enfyra_column_rule?limit=1000'),
2173
- getMethodIdNameMap(),
2174
- ]);
2175
- return {
2176
- ...metadataContext,
2177
- routes,
2178
- handlers,
2179
- preHooks,
2180
- postHooks,
2181
- routePermissions,
2182
- guards,
2183
- guardRules,
2184
- fieldPermissions,
2185
- columnRules,
2186
- methodIdNameMap,
2187
- };
2188
- }
2189
- async function collectFeatureSearchState() {
2190
- const metadata = await discoveryFetch('/metadata');
2191
- const tableCatalogResult = await discoveryFetch('/enfyra_table?fields=id,name,alias,description,isSingleRecord&limit=0&sort=name');
2192
- const routesResult = await discoveryFetch('/enfyra_route?limit=500');
2193
- const handlersResult = await discoveryFetch('/enfyra_route_handler?limit=500');
2194
- const preHooksResult = await discoveryFetch('/enfyra_pre_hook?limit=500');
2195
- const postHooksResult = await discoveryFetch('/enfyra_post_hook?limit=500');
2196
- const routePermissionsResult = await discoveryFetch('/enfyra_route_permission?limit=500');
2197
- const guardsResult = await discoveryFetch('/enfyra_guard?limit=500');
2198
- const guardRulesResult = await discoveryFetch('/enfyra_guard_rule?limit=500');
2199
- const fieldPermissionsResult = await discoveryFetch('/enfyra_field_permission?limit=500');
2200
- const columnRulesResult = await discoveryFetch('/enfyra_column_rule?limit=500');
2201
- const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
2202
- const methodIdNameMap = Object.fromEntries(unwrapData(methodsResult).map((method) => [String(getId(method)), method.name]));
2203
- const tableCatalog = unwrapData(tableCatalogResult);
2204
- return {
2205
- metadata,
2206
- tables: await fetchMetadataTables(ENFYRA_API_URL, tableCatalog),
2207
- routes: unwrapData(routesResult),
2208
- handlers: unwrapData(handlersResult),
2209
- preHooks: unwrapData(preHooksResult),
2210
- postHooks: unwrapData(postHooksResult),
2211
- routePermissions: unwrapData(routePermissionsResult),
2212
- guards: unwrapData(guardsResult),
2213
- guardRules: unwrapData(guardRulesResult),
2214
- fieldPermissions: unwrapData(fieldPermissionsResult),
2215
- columnRules: unwrapData(columnRulesResult),
2216
- methodIdNameMap,
2217
- partialErrors: collectPartialErrors({
2218
- metadata,
2219
- tableCatalogResult,
2220
- routesResult,
2221
- handlersResult,
2222
- preHooksResult,
2223
- postHooksResult,
2224
- routePermissionsResult,
2225
- guardsResult,
2226
- guardRulesResult,
2227
- fieldPermissionsResult,
2228
- columnRulesResult,
2229
- methodsResult,
2230
- }),
2231
- };
2232
- }
2233
- function enrichRoute(route, state) {
2234
- const routeId = getId(route);
2235
- const routeHandlers = state.handlers
2236
- .filter((item) => sameId(refId(item.route), routeId))
2237
- .map((item) => pickCodeSummary({
2238
- ...item,
2239
- method: item.method ? {
2240
- ...item.method,
2241
- name: state.methodIdNameMap[String(getId(item.method))] || item.method.name || null,
2242
- } : item.method,
2243
- }, 'sourceCode'));
2244
- const routePreHooks = withMethodNames(state.preHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)), state.methodIdNameMap).map((item) => pickCodeSummary(item, 'code'));
2245
- const routePostHooks = withMethodNames(state.postHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)), state.methodIdNameMap).map((item) => pickCodeSummary(item, 'code'));
2246
- const routePermissions = withMethodNames(state.routePermissions.filter((item) => sameId(refId(item.route), routeId)), state.methodIdNameMap);
2247
- const routeGuards = withMethodNames(state.guards.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)), state.methodIdNameMap).map((guard) => ({
2248
- ...guard,
2249
- rules: state.guardRules.filter((rule) => sameId(refId(rule.guard), getId(guard))),
2250
- }));
2251
- return {
2252
- ...route,
2253
- availableMethods: Array.isArray(route.availableMethods)
2254
- ? route.availableMethods.map((method) => ({
2255
- ...method,
2256
- name: method.name || state.methodIdNameMap[String(getId(method))] || null,
2257
- }))
2258
- : route.availableMethods,
2259
- publicMethods: Array.isArray(route.publicMethods)
2260
- ? route.publicMethods.map((method) => ({
2261
- ...method,
2262
- name: method.name || state.methodIdNameMap[String(getId(method))] || null,
2263
- }))
2264
- : route.publicMethods,
2265
- skipRoleGuardMethods: Array.isArray(route.skipRoleGuardMethods)
2266
- ? route.skipRoleGuardMethods.map((method) => ({
2267
- ...method,
2268
- name: method.name || state.methodIdNameMap[String(getId(method))] || null,
2269
- }))
2270
- : route.skipRoleGuardMethods,
2271
- handlers: routeHandlers,
2272
- preHooks: routePreHooks,
2273
- postHooks: routePostHooks,
2274
- routePermissions,
2275
- guards: routeGuards,
2276
- };
2277
- }
2278
- server.tool('inspect_table', [
2279
- 'REST-first inspection for one table. Use before writing code, filters, permissions, validation, or routes for a table.',
2280
- 'Returns columns, relations, route-backed REST paths, route handlers/hooks/guards/permissions, field permissions, and column validation rules.',
2281
- ].join(' '), {
2282
- tableName: z.string().describe('Table name or alias to inspect'),
2283
- }, async ({ tableName }) => {
2284
- const state = await collectRestDefinitionState(tableName);
2285
- const table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
2286
- if (!table) {
2287
- throw new Error(`Unknown table "${tableName}". Use get_all_tables({ search, limit }) or get_all_metadata({ search, all: true }) to confirm the table name. If a just-created table is missing, verify the create response/reload event before calling manual reload tools.`);
2288
- }
2289
- const tableId = getId(table);
2290
- const columnIds = new Set((table.columns || []).map((column) => String(getId(column))));
2291
- const relationIds = new Set((table.relations || []).map((relation) => String(getId(relation))));
2292
- const routes = state.routes.filter((route) => sameId(refId(route.mainTable), tableId));
2293
- const payload = {
2294
- table: summarizeTable(table),
2295
- database: getMetadataDatabaseContext(state.metadata),
2296
- rest: {
2297
- routePattern: 'GET/POST /<path>; PATCH/DELETE /<path>/:id; no dynamic GET /<path>/:id.',
2298
- routes: routes.map((route) => enrichRoute(route, state)),
2299
- routeBacked: routes.length > 0,
2300
- },
2301
- validation: {
2302
- validateBody: table.validateBody,
2303
- columnRules: state.columnRules.filter((rule) => columnIds.has(String(refId(rule.column)))),
2304
- },
2305
- permissions: {
2306
- fieldPermissions: state.fieldPermissions.filter((permission) => (permission.column && columnIds.has(String(refId(permission.column)))) || (permission.relation && relationIds.has(String(refId(permission.relation))))),
2307
- },
2308
- queryGuidance: {
2309
- fields: 'Use column names and relation propertyName values.',
2310
- filter: 'Use query DSL operators on column names or nested relation propertyName objects.',
2311
- deep: 'Deep fetch keys are relation propertyName values.',
2312
- relationMutation: 'For relation schema creation/update use targetTable/type/propertyName/inversePropertyName|mappedBy/isNullable/onDelete only. Do not provide physical FK/junction columns; Enfyra derives and hides them. Omit inversePropertyName unless a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal needs it.',
2313
- },
2314
- };
2315
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2316
- });
2317
- server.tool('inspect_route', [
2318
- 'REST-first inspection for a route/path. Use before changing handlers, hooks, permissions, guards, or testing an endpoint.',
2319
- 'Returns the backing table, available/public methods, handlers, hooks, route permissions, guards, and exact REST URL pattern.',
2320
- ].join(' '), {
2321
- path: z.string().optional().describe('Route path, e.g. /enfyra_user'),
2322
- routeId: z.union([z.string(), z.number()]).optional().describe('enfyra_route id. Use either path or routeId.'),
2323
- }, async ({ path, routeId }) => {
2324
- if (!path && !routeId)
2325
- throw new Error('Provide path or routeId');
2326
- const state = await collectRestDefinitionState();
2327
- const route = state.routes.find((item) => (routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)));
2328
- if (!route)
2329
- throw new Error(`Route not found: ${routeId || path}`);
2330
- const table = route.mainTable
2331
- ? await fetchTableMetadataByRef(ENFYRA_API_URL, refId(route.mainTable))
2332
- : null;
2333
- const payload = {
2334
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
2335
- route: enrichRoute(route, state),
2336
- mainTable: summarizeTable(table),
2337
- restPattern: {
2338
- listOrCreate: `${ENFYRA_API_URL.replace(/\/$/, '')}${route.path}`,
2339
- updateOrDelete: `${ENFYRA_API_URL.replace(/\/$/, '')}${route.path}/<id>`,
2340
- oneById: `Use GET ${route.path}?filter=${JSON.stringify({ [getPrimaryColumn(table)?.name || 'id']: { _eq: '<id>' } })}&limit=1`,
2341
- },
2342
- };
2343
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2344
- });
2345
- server.tool('inspect_feature', [
2346
- 'Search live REST/system metadata for a feature name, route path, table, handler, hook, guard, or permission.',
2347
- 'Use when the user mentions a capability and you need to find where it lives before editing. Keep the query specific; broad searches return bounded summaries.',
2348
- ].join(' '), {
2349
- query: z.string().describe('Feature keyword, table name, route path, handler text, hook name, or guard name'),
2350
- limit: z.number().int().positive().max(25).optional().default(8).describe('Maximum matches returned per section. Default 8 to keep output small.'),
2351
- }, async ({ query, limit }) => {
2352
- const rawQuery = String(query || '').trim();
2353
- if (rawQuery.length < 2) {
2354
- throw new Error('inspect_feature query must be at least 2 characters. Use a table name, route path, event name, or specific feature keyword.');
2355
- }
2356
- const max = Math.max(1, Math.min(Number(limit || 8), 25));
2357
- const state = await collectFeatureSearchState();
2358
- const q = rawQuery.toLowerCase();
2359
- const matchesText = (value) => JSON.stringify(value ?? '').toLowerCase().includes(q);
2360
- const tableMatches = state.tables.filter((table) => matchesText({
2361
- name: table.name,
2362
- alias: table.alias,
2363
- description: table.description,
2364
- columns: table.columns?.map((column) => ({ name: column.name, description: column.description })),
2365
- relations: table.relations?.map((relation) => ({ propertyName: relation.propertyName, description: relation.description })),
2366
- }));
2367
- const routeMatches = state.routes.filter((route) => matchesText(route));
2368
- const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, 'sourceCode'));
2369
- const preHookMatches = state.preHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
2370
- const postHookMatches = state.postHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
2371
- const guardMatches = state.guards.filter((guard) => matchesText(guard));
2372
- const permissionMatches = [
2373
- ...state.routePermissions.filter((permission) => matchesText(permission)).map((permission) => ({ type: 'route_permission', ...permission })),
2374
- ...state.fieldPermissions.filter((permission) => matchesText(permission)).map((permission) => ({ type: 'field_permission', ...permission })),
2375
- ];
2376
- const payload = {
2377
- targetInstance: targetInstance(),
2378
- query: rawQuery,
2379
- limit: max,
2380
- partialErrors: state.partialErrors,
2381
- counts: {
2382
- tables: tableMatches.length,
2383
- routes: routeMatches.length,
2384
- handlers: handlerMatches.length,
2385
- preHooks: preHookMatches.length,
2386
- postHooks: postHookMatches.length,
2387
- guards: guardMatches.length,
2388
- permissions: permissionMatches.length,
2389
- },
2390
- tables: tableMatches.slice(0, max).map(summarizeTable),
2391
- routes: routeMatches.slice(0, max).map((route) => enrichRoute(route, state)),
2392
- handlers: handlerMatches.slice(0, max),
2393
- preHooks: preHookMatches.slice(0, max),
2394
- postHooks: postHookMatches.slice(0, max),
2395
- guards: guardMatches.slice(0, max),
2396
- permissions: permissionMatches.slice(0, max),
2397
- detailHint: 'For a specific match, call inspect_table, inspect_route, trace_metadata_usage, or get_script_source instead of broadening this search.',
2398
- };
2399
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2400
- });
2401
- server.tool('trace_metadata_usage', [
2402
- 'Trace where a table, route path, keyword, or script fragment appears across live metadata and script-backed records.',
2403
- 'Use this before changing production flows/handlers/hooks to find all callers or writers for a table such as cloud_provisioning_history.',
2404
- ].join(' '), {
2405
- query: z.string().describe('Table name, route path, field name, event name, or source-code keyword to trace'),
2406
- includeSourcePreview: z.boolean().optional().default(true).describe('Include short source previews around matches.'),
2407
- limit: z.number().optional().default(25).describe('Maximum matches per section.'),
2408
- }, async ({ query, includeSourcePreview, limit }) => {
2409
- const q = String(query || '').trim();
2410
- if (!q)
2411
- throw new Error('query is required.');
2412
- const lower = q.toLowerCase();
2413
- const max = Math.max(1, Math.min(Number(limit || 25), 100));
2414
- const state = await collectFeatureSearchState();
2415
- const contains = (value) => JSON.stringify(value ?? '').toLowerCase().includes(lower);
2416
- const sourceContains = (record) => getRecordSource(record).sourceCode.toLowerCase().includes(lower);
2417
- const scriptTableResults = await Promise.all(SCRIPT_BACKED_TABLES.map(async (tableName) => {
2418
- const fields = scriptTraceFields(tableName);
2419
- let result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=${encodeURIComponent(fields)}`).catch((error) => ({ error }));
2420
- if (result?.error && fields !== '*') {
2421
- result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=*`).catch((error) => ({ error }));
2422
- }
2423
- return { tableName, records: unwrapData(result), error: result?.error?.message || null };
2424
- }));
2425
- const scriptMatches = [];
2426
- const scriptErrors = [];
2427
- for (const { tableName, records, error } of scriptTableResults) {
2428
- if (error) {
2429
- scriptErrors.push({ tableName, error });
2430
- continue;
2431
- }
2432
- for (const record of records) {
2433
- const { field, sourceCode } = getRecordSource(record);
2434
- if (!field || !sourceContains(record))
2435
- continue;
2436
- scriptMatches.push({
2437
- ...scriptRecordLabel(tableName, record),
2438
- sourceField: field,
2439
- sourceLength: sourceCode.length,
2440
- sourceSha256: sha256(sourceCode),
2441
- preview: includeSourcePreview ? sourcePreview(sourceCode, q) : undefined,
2442
- });
2443
- }
2444
- }
2445
- const tableMatches = state.tables.filter((table) => contains({
2446
- name: table.name,
2447
- alias: table.alias,
2448
- description: table.description,
2449
- columns: (table.columns || []).map((column) => ({ name: column.name, type: column.type, description: column.description })),
2450
- relations: (table.relations || []).map((relation) => ({ propertyName: relation.propertyName, type: relation.type, description: relation.description })),
2451
- }));
2452
- const routeMatches = state.routes.filter((route) => contains({
2453
- path: route.path,
2454
- mainTable: route.mainTable,
2455
- description: route.description,
2456
- }));
2457
- const fieldPermissionMatches = state.fieldPermissions.filter((permission) => contains(permission));
2458
- const guardMatches = state.guards.filter((guard) => contains(guard));
2459
- const routePermissionMatches = state.routePermissions.filter((permission) => contains(permission));
2460
- return { content: [{ type: 'text', text: JSON.stringify({
2461
- query: q,
2462
- counts: {
2463
- tables: tableMatches.length,
2464
- routes: routeMatches.length,
2465
- scripts: scriptMatches.length,
2466
- fieldPermissions: fieldPermissionMatches.length,
2467
- routePermissions: routePermissionMatches.length,
2468
- guards: guardMatches.length,
2469
- },
2470
- tables: tableMatches.map(summarizeTable).slice(0, max),
2471
- routes: routeMatches.map((route) => enrichRoute(route, state)).slice(0, max),
2472
- scripts: scriptMatches.slice(0, max),
2473
- fieldPermissions: fieldPermissionMatches.slice(0, max),
2474
- routePermissions: routePermissionMatches.slice(0, max),
2475
- guards: guardMatches.slice(0, max),
2476
- scriptReadErrors: scriptErrors,
2477
- next: 'Use inspect_route/inspect_table for structure, get_script_source for full source, and patch_script_source for exact validated edits.',
2478
- }, null, 2) }] };
2479
- });
2480
- server.tool('test_rest_endpoint', [
2481
- 'Execute a real REST request against the configured Enfyra API base.',
2482
- 'Use this after inspecting a route or changing handlers/hooks/guards. Pass paths like /enfyra_table?limit=1, not external URLs.',
2483
- 'Do not use this for admin app page/menu routes such as /cloud/projects/:id unless inspect_route confirms an API route with that exact path.',
2484
- ].join(' '), {
2485
- method: z.string().optional().default('GET').describe('HTTP method name. Must exist in enfyra_method.name for Enfyra route-backed calls.'),
2486
- path: z.string().describe('Enfyra API path, e.g. /enfyra_route?limit=1'),
2487
- query: z.string().optional().describe('Optional JSON-encoded query object string, e.g. {"limit":1,"filter":{"status":{"_eq":"ready"}}}; merged onto the path query string.'),
2488
- body: z.string().optional().describe('Optional JSON request body string, e.g. {"title":"Example"}.'),
2489
- headers: z.string().optional().describe('Optional JSON-encoded headers object string.'),
2490
- useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test public access.'),
2491
- }, async ({ method, path, query, body, headers, useAuth }) => {
2492
- const httpMethod = normalizeMethodNameInput(method || 'GET');
2493
- const restPath = normalizeRestPath(path);
2494
- const url = new URL(`${ENFYRA_API_URL.replace(/\/$/, '')}${restPath}`);
2495
- const queryObj = parseJsonArg(query, {});
2496
- for (const [key, value] of Object.entries(queryObj || {})) {
2497
- url.searchParams.set(key, typeof value === 'string' ? value : JSON.stringify(value));
2498
- }
2499
- const requestHeaders = {
2500
- 'Content-Type': 'application/json',
2501
- ...(parseJsonArg(headers, {}) || {}),
2502
- };
2503
- if (useAuth) {
2504
- requestHeaders.Authorization = `Bearer ${await getValidToken(ENFYRA_API_URL)}`;
2505
- }
2506
- const started = Date.now();
2507
- const response = await fetch(url, {
2508
- method: httpMethod,
2509
- headers: requestHeaders,
2510
- ...(body !== undefined && body !== null && httpMethod !== 'GET' ? { body } : {}),
2511
- });
2512
- const contentType = response.headers.get('content-type') || '';
2513
- const responseText = await response.text();
2514
- let parsedBody = responseText;
2515
- if (contentType.includes('application/json') && responseText) {
2516
- parsedBody = JSON.parse(responseText);
2517
- }
2518
- const payload = {
2519
- request: {
2520
- method: httpMethod,
2521
- url: url.toString(),
2522
- authenticated: !!useAuth,
2523
- },
2524
- response: {
2525
- ok: response.ok,
2526
- status: response.status,
2527
- statusText: response.statusText,
2528
- contentType,
2529
- durationMs: Date.now() - started,
2530
- body: parsedBody,
2531
- },
2532
- };
2533
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2534
- });
2535
- server.tool('test_graphql', [
2536
- 'Execute a real GraphQL operation against the configured Enfyra /graphql endpoint.',
2537
- 'Use this after set_table_graphql or when verifying generated query/mutation behavior. GraphQL errors are returned as structured response data even when HTTP status is 200.',
2538
- ].join(' '), {
2539
- query: z.string().describe('GraphQL query or mutation document.'),
2540
- variables: z.record(z.any()).optional().describe('GraphQL variables as a native JSON object.'),
2541
- operationName: z.string().optional().describe('Optional operation name when the document contains multiple operations.'),
2542
- useAuth: z.boolean().optional().default(true).describe('Attach the MCP admin Bearer token. Set false to verify anonymous GraphQL behavior.'),
2543
- }, async ({ query, variables, operationName, useAuth }) => {
2544
- const headers = { 'Content-Type': 'application/json' };
2545
- if (useAuth)
2546
- headers.Authorization = `Bearer ${await getValidToken(ENFYRA_API_URL)}`;
2547
- const started = Date.now();
2548
- const response = await fetch(`${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`, {
2549
- method: 'POST',
2550
- headers,
2551
- body: JSON.stringify({
2552
- query,
2553
- ...(variables ? { variables } : {}),
2554
- ...(operationName ? { operationName } : {}),
2555
- }),
2556
- });
2557
- const contentType = response.headers.get('content-type') || '';
2558
- const responseText = await response.text();
2559
- let responseBody = responseText;
2560
- if (contentType.includes('application/json') && responseText)
2561
- responseBody = JSON.parse(responseText);
2562
- const errors = Array.isArray(responseBody?.errors) ? responseBody.errors : [];
2563
- return jsonContent({
2564
- action: 'graphql_tested',
2565
- request: {
2566
- endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
2567
- operationName: operationName || null,
2568
- authenticated: !!useAuth,
2569
- variableNames: Object.keys(variables || {}),
2570
- },
2571
- response: {
2572
- ok: response.ok && errors.length === 0,
2573
- httpOk: response.ok,
2574
- status: response.status,
2575
- statusText: response.statusText,
2576
- durationMs: Date.now() - started,
2577
- errorCount: errors.length,
2578
- data: responseBody?.data ?? null,
2579
- errors,
2580
- raw: responseBody && typeof responseBody === 'object' ? undefined : responseBody,
2581
- },
2582
- });
2583
- });
2584
- server.tool('get_all_routes', 'List route definitions with minimal fields. Complete route lists must pass either limit or all=true. If search is provided without limit, the tool returns a bounded lookup window of 10 matches. Call inspect_route for handlers/hooks/permissions detail.', {
2585
- includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
2586
- search: z.string().optional().describe('Optional path or table substring filter. Use this before creating a route to check duplicates.'),
2587
- limit: z.number().int().positive().optional().describe('Maximum routes returned after search. Required unless all=true or search is provided. Do not invent arbitrary limits for "all"; use all=true instead.'),
2588
- all: z.boolean().optional().default(false).describe('Return all matched routes. Use this when the user asks for all routes or a complete route list.'),
2589
- }, async ({ includeDisabled, search, limit, all }) => {
2590
- if (!all && limit === undefined && !search?.trim()) {
2591
- throw new Error('get_all_routes requires either limit or all=true. Do not rely on implicit default page sizes.');
2592
- }
2593
- if (all && limit !== undefined) {
2594
- throw new Error('get_all_routes accepts either all=true or limit, not both.');
2595
- }
2596
- const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
2597
- const queryParams = new URLSearchParams({
2598
- filter: JSON.stringify(filter),
2599
- fields: 'id,path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled',
2600
- limit: all ? '0' : '1000',
2601
- });
2602
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${queryParams.toString()}`);
2603
- const q = search ? search.toLowerCase() : null;
2604
- const allRoutes = summarizeRoutes(result);
2605
- const matchedRoutes = q
2606
- ? allRoutes.filter((route) => JSON.stringify({
2607
- path: route.path,
2608
- mainTable: route.mainTable,
2609
- }).toLowerCase().includes(q))
2610
- : allRoutes;
2611
- const routeLimit = all ? matchedRoutes.length : (limit ?? 10);
2612
- const payload = {
2613
- statusCode: result?.statusCode,
2614
- success: result?.success,
2615
- totalRouteCount: allRoutes.length,
2616
- matchedRouteCount: matchedRoutes.length,
2617
- returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
2618
- all: !!all,
2619
- implicitSearchLimit: Boolean(!all && limit === undefined && search?.trim()),
2620
- complete: all || routeLimit >= matchedRoutes.length,
2621
- hardCap: all ? null : routeLimit,
2622
- search: search || null,
2623
- routes: matchedRoutes.slice(0, routeLimit),
2624
- detailHint: matchedRoutes.length > routeLimit
2625
- ? `Response truncated to ${routeLimit} routes. Re-run with search or a higher limit, then inspect_route({ path }) for details.`
2626
- : 'Use inspect_route({ path }) or inspect_route({ routeId }) for handlers, hooks, permissions, and guards.',
2627
- };
2628
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2629
- });
2630
- server.tool('create_route', [
2631
- '**Use this when the user wants a new REST API route or path** — not `create_tables`. Custom routes must omit `mainTableId`.',
2632
- '`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/reports/summary`, `/auth/login`, or any custom path.',
2633
- 'Do NOT create a new enfyra_table only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query user-facing tables through secure explicit repos such as `#secure.orders` or `$ctx.$repos.secure.orders`.',
2634
- 'availableMethods = which REST verbs the route responds to. publicMethods = which REST verbs are public (no auth). GraphQL is enabled separately through enfyra_graphql/update_tables graphqlEnabled.',
2635
- 'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
2636
- 'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
2637
- ].join(' '), {
2638
- path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
2639
- mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
2640
- methods: z.array(z.string())
2641
- .describe('HTTP method names this route supports (availableMethods). Each value must exist in enfyra_method.name. Common: ["GET","POST","PATCH","DELETE"].'),
2642
- publicMethods: z.array(z.string()).optional()
2643
- .describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
2644
- isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
2645
- description: z.string().optional().describe('Route description'),
2646
- globalRulesAckKey: globalRulesAckParam(z),
2647
- }, async ({ path: routePath, mainTableId, methods, publicMethods, isEnabled, description, globalRulesAckKey }) => {
2648
- assertGlobalRulesAck(globalRulesAckKey);
2649
- const methodMap = await getMethodMap();
2650
- const normalizedPath = normalizeRestPath(routePath);
2651
- const body = {
2652
- path: normalizedPath,
2653
- isEnabled,
2654
- description,
2655
- availableMethods: resolveMethodIds(methodMap, methods),
2656
- };
2657
- if (mainTableId !== undefined && mainTableId !== null) {
2658
- const { tables } = await getMetadataTables(mainTableId);
2659
- validateMainTableRoutePath(tables, mainTableId, normalizedPath);
2660
- body.mainTable = { id: mainTableId };
2661
- }
2662
- if (publicMethods && publicMethods.length > 0) {
2663
- body.publicMethods = resolveMethodIds(methodMap, publicMethods);
2664
- }
2665
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
2666
- method: 'POST',
2667
- body: JSON.stringify(body),
2668
- });
2669
- const routeReload = await reloadRoutesResult();
2670
- const created = firstDataRecord(result);
2671
- return { content: [{ type: 'text', text: JSON.stringify({
2672
- action: 'created',
2673
- route: {
2674
- id: getId(created),
2675
- path: created?.path,
2676
- mainTableId: mainTableId ?? null,
2677
- availableMethods: methods,
2678
- publicMethods: publicMethods || [],
2679
- },
2680
- routeReload,
2681
- next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET", sourceCode }) for custom code. Create extra enfyra_method.name rows first for custom methods such as PUT.`,
2682
- }, null, 2) }] };
2683
- });
2684
- server.tool('create_handler', [
2685
- 'Create a handler for a route+method. One handler per (route, method) pair.',
2686
- 'Attach to a custom route from `create_route` for endpoint-specific or third-party behavior. Do not use this low-level tool to bypass api_endpoint_workflow canonical-collision checks.',
2687
- 'Canonical table routes are shared with eApp/admin CRUD. Adding a new canonical handler requires allowCanonicalRoute=true and main-table repository access; otherwise create a separate custom path.',
2688
- 'Use sourceCode, not logic/name. Enfyra compiles sourceCode into compiledCode; do not send compiledCode.',
2689
- 'Handler code runs inside a sandbox with $ctx. Use macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503, @SOCKET, @PKGS, @LOGS, @SHARE.',
2690
- 'Call discover_script_contexts first. For explicit user-facing table repos use #secure.table_name or @REPOS.secure.table_name; use #table_name/@REPOS.table_name only for intentional trusted internal access.',
2691
- 'Or use $ctx directly: $ctx.$body, $ctx.$repos.main.find(), $ctx.$helpers.$bcrypt.hash(), etc.',
2692
- 'require("pkg") works for installed Server packages. console.log() writes to $share.$logs.',
2693
- ].join(' '), {
2694
- routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
2695
- method: z.string().optional()
2696
- .describe('Single enfyra_method.name to create. Prefer this for one handler.'),
2697
- methods: z.array(z.string()).optional()
2698
- .describe('Batch create multiple handlers. Use only when the same sourceCode applies to every method.'),
2699
- sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic. Use @REPOS.main for the route main table or #secure.table_name/@REPOS.secure.table_name for explicit user-facing access; trusted repos require intentional bypass and explicit authorization.'),
2700
- scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2701
- timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
2702
- globalRulesAckKey: globalRulesAckParam(z),
2703
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2704
- allowCanonicalRoute: z.boolean().optional().default(false).describe('Explicit acknowledgement for adding a new handler to a canonical main-table route. Use only when the new method intentionally belongs to the shared eApp/admin CRUD surface; third-party endpoint-specific behavior must use a separate custom route.'),
2705
- }, async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout, globalRulesAckKey, knowledgeAckKey, allowCanonicalRoute }) => {
2706
- assertGlobalRulesAck(globalRulesAckKey);
2707
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2708
- const routeQuery = new URLSearchParams({
2709
- filter: JSON.stringify({ id: { _eq: routeId } }),
2710
- fields: 'id,path,mainTable.id,mainTable.name',
2711
- limit: '1',
2712
- });
2713
- const routeResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${routeQuery.toString()}`);
2714
- const targetRoute = unwrapData(routeResult)[0];
2715
- if (!targetRoute)
2716
- throw new Error(`Route not found: ${String(routeId)}`);
2717
- assertCreateHandlerRouteBoundary(targetRoute, sourceCode, allowCanonicalRoute);
2718
- const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
2719
- if (methodNames.length === 0)
2720
- throw new Error('Provide method or methods');
2721
- const methodMap = await getMethodMap();
2722
- const results = [];
2723
- const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
2724
- sourceCode,
2725
- scriptLanguage,
2726
- });
2727
- for (const methodName of methodNames) {
2728
- const methodId = methodMap[methodName.toUpperCase()];
2729
- if (!methodId)
2730
- throw new Error(`Unknown method: ${methodName}. Valid: ${Object.keys(methodMap).join(', ')}`);
2731
- const body = { route: { id: routeId }, method: { id: methodId }, sourceCode, scriptLanguage };
2732
- if (timeout)
2733
- body.timeout = timeout;
2734
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_handler', {
2735
- method: 'POST',
2736
- body: JSON.stringify(body),
2737
- });
2738
- const created = firstDataRecord(result);
2739
- results.push({
2740
- id: getId(created),
2741
- routeId,
2742
- method: methodName,
2743
- scriptLanguage,
2744
- timeout: created?.timeout ?? timeout ?? null,
2745
- });
2746
- }
2747
- const routeReload = await reloadRoutesResult();
2748
- return { content: [{ type: 'text', text: JSON.stringify({
2749
- action: 'created',
2750
- handlers: results,
2751
- canonicalRouteAcknowledged: Boolean(targetRoute?.mainTable && allowCanonicalRoute),
2752
- scriptValidation,
2753
- routeReload,
2754
- detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
2755
- }, null, 2) }] };
2756
- });
2757
- server.tool('create_pre_hook', [
2758
- 'Create a pre-hook that runs BEFORE the handler. Use to validate, transform, inject data, or enforce owner/tenant row filters (RLS).',
2759
- 'Use `routeId` from `create_route` or `get_all_routes` — do not create a new table just to get a route id.',
2760
- 'Macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503.',
2761
- 'For canonical table reads, merge security filters into @QUERY.filter and preserve @QUERY.fields/deep/sort/limit/page/meta/aggregate.',
2762
- 'If the hook returns a value, that value becomes the response (handler is skipped).',
2763
- ].join(' '), {
2764
- routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
2765
- name: z.string().describe('Hook name (unique per route)'),
2766
- code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
2767
- scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2768
- methods: z.array(z.string()).optional()
2769
- .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2770
- priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2771
- isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2772
- globalRulesAckKey: globalRulesAckParam(z),
2773
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2774
- }, async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, globalRulesAckKey, knowledgeAckKey }) => {
2775
- assertGlobalRulesAck(globalRulesAckKey);
2776
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2777
- const methodMap = await getMethodMap();
2778
- const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2779
- const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_pre_hook', {
2780
- sourceCode: code,
2781
- scriptLanguage,
2782
- });
2783
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_pre_hook', {
2784
- method: 'POST',
2785
- body: JSON.stringify({
2786
- route: { id: routeId },
2787
- name,
2788
- sourceCode: code,
2789
- scriptLanguage,
2790
- methods: resolveMethodIds(methodMap, methodNames),
2791
- priority,
2792
- isEnabled,
2793
- }),
2794
- });
2795
- const routeReload = await reloadRoutesResult();
2796
- const created = firstDataRecord(result);
2797
- return { content: [{ type: 'text', text: JSON.stringify({
2798
- action: 'created',
2799
- kind: 'pre_hook',
2800
- id: getId(created),
2801
- name,
2802
- routeId,
2803
- scriptValidation,
2804
- routeReload,
2805
- }, null, 2) }] };
2806
- });
2807
- server.tool('create_post_hook', [
2808
- 'Create a post-hook that runs AFTER the handler. Use to transform responses or add metadata.',
2809
- 'Use `routeId` from `create_route` or `get_all_routes` — do not create a new table just to get a route id.',
2810
- 'Macros: @DATA, @STATUS, @ERROR, @BODY, @QUERY, @USER, @SHARE, @API (post-hooks always run; on error path @ERROR is set, @DATA is null).',
2811
- 'Mutate @DATA / $ctx.$data in place, or return a value: if the hook returns anything other than undefined, that value replaces $ctx.$data as the response payload.',
2812
- ].join(' '), {
2813
- routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
2814
- name: z.string().describe('Hook name (unique per route)'),
2815
- code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
2816
- scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2817
- methods: z.array(z.string()).optional()
2818
- .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2819
- priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2820
- isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2821
- globalRulesAckKey: globalRulesAckParam(z),
2822
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2823
- }, async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, globalRulesAckKey, knowledgeAckKey }) => {
2824
- assertGlobalRulesAck(globalRulesAckKey);
2825
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2826
- const methodMap = await getMethodMap();
2827
- const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2828
- const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_post_hook', {
2829
- sourceCode: code,
2830
- scriptLanguage,
2831
- });
2832
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_post_hook', {
2833
- method: 'POST',
2834
- body: JSON.stringify({
2835
- route: { id: routeId },
2836
- name,
2837
- sourceCode: code,
2838
- scriptLanguage,
2839
- methods: resolveMethodIds(methodMap, methodNames),
2840
- priority,
2841
- isEnabled,
2842
- }),
2843
- });
2844
- const routeReload = await reloadRoutesResult();
2845
- const created = firstDataRecord(result);
2846
- return { content: [{ type: 'text', text: JSON.stringify({
2847
- action: 'created',
2848
- kind: 'post_hook',
2849
- id: getId(created),
2850
- name,
2851
- routeId,
2852
- scriptValidation,
2853
- routeReload,
2854
- }, null, 2) }] };
2855
- });
2856
- server.tool('audit_route_access', [
2857
- 'Audit route access for one or more routes.',
2858
- 'Use this before granting access or debugging 403s. It reports available methods, public methods, skipRoleGuard methods, route permissions, and optional missing methods for one role/user scope.',
2859
- ].join(' '), {
2860
- path: z.string().optional().describe('Exact route path, e.g. /orders'),
2861
- routeId: z.union([z.string(), z.number()]).optional().describe('Exact route id'),
2862
- search: z.string().optional().describe('Optional route path search when path/routeId is not provided'),
2863
- roleId: z.union([z.string(), z.number()]).optional().describe('Expected role id to check'),
2864
- roleName: z.string().optional().describe('Expected role name to resolve, e.g. user'),
2865
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Expected direct/specific user ids to check'),
2866
- methods: z.array(z.string()).optional().describe('Methods expected to be allowed for this scope'),
2867
- limit: z.number().int().positive().max(100).optional().default(25).describe('Maximum routes returned for search mode'),
2868
- }, async ({ path, routeId, search, roleId, roleName, allowedUserIds, methods, limit }) => {
2869
- if ([path, routeId, search].filter((value) => value !== undefined && value !== null && value !== '').length > 1) {
2870
- throw new Error('Use only one of path, routeId, or search.');
2871
- }
2872
- if (roleId && roleName)
2873
- throw new Error('Provide roleId or roleName, not both.');
2874
- const [routes, routePermissions, roles, methodIdNameMap] = await Promise.all([
2875
- fetchAll('/enfyra_route?limit=1000'),
2876
- fetchAll('/enfyra_route_permission?limit=1000'),
2877
- fetchAll('/enfyra_role?limit=1000'),
2878
- getMethodIdNameMap(),
2879
- ]);
2880
- const role = resolveRoleByNameOrId(roles, { roleId, roleName });
2881
- const normalizedPath = path ? normalizeRestPath(path) : null;
2882
- const query = search ? String(search).toLowerCase() : null;
2883
- const matchedRoutes = routes.filter((route) => {
2884
- if (routeId)
2885
- return sameId(getId(route), routeId);
2886
- if (normalizedPath)
2887
- return route.path === normalizedPath;
2888
- if (query)
2889
- return String(route.path || '').toLowerCase().includes(query);
2890
- return true;
2891
- }).slice(0, limit);
2892
- const expectedMethods = normalizeMethodNames(methods || []);
2893
- const payload = {
2894
- guidance: {
2895
- publicAccess: 'publicMethods bypass RoleGuard and do not require enfyra_route_permission.',
2896
- authenticatedAccess: 'For non-public methods, Enfyra admin UI PermissionGate and backend RoleGuard both expect enabled enfyra_route_permission rows with matching route + HTTP method.',
2897
- directUserAccess: 'allowedRoutePermissions on /me represent direct user-scoped route permissions; role.routePermissions represent role-scoped permissions.',
2898
- },
2899
- expectedScope: {
2900
- role: role ? { id: getId(role), name: role.name } : null,
2901
- allowedUserIds: allowedUserIds || [],
2902
- methods: expectedMethods,
2903
- },
2904
- returnedRouteCount: matchedRoutes.length,
2905
- routes: matchedRoutes.map((route) => summarizeRouteAccess(route, routePermissions, methodIdNameMap, {
2906
- roleId: role ? getId(role) : roleId,
2907
- roleRequired: !!(role || roleId || roleName),
2908
- allowedUserIds,
2909
- methods: expectedMethods,
2910
- })),
2911
- };
2912
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2913
- });
2914
- server.tool('ensure_route_access', [
2915
- 'Create or update authenticated route access for one role/user scope.',
2916
- 'Use this instead of raw enfyra_route_permission CRUD when fixing 403s. It resolves roleName/route/method ids, validates route.availableMethods, merges existing permission methods by default, and reloads routes.',
2917
- ].join(' '), {
2918
- path: z.string().optional().describe('Route path, e.g. /orders'),
2919
- routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
2920
- methods: z.array(z.string()).describe('HTTP method names to allow, e.g. ["GET", "POST"].'),
2921
- roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2922
- roleName: z.string().optional().describe('Role name scope, e.g. user. Prefer this when an LLM does not know role ids.'),
2923
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope. Omit for role-wide access.'),
2924
- mode: z.enum(['merge', 'replace']).optional().default('merge').describe('merge adds methods to an existing permission; replace overwrites methods on the matched permission.'),
2925
- description: z.string().optional().describe('Admin note'),
2926
- isEnabled: z.boolean().optional().default(true).describe('Enable the permission'),
2927
- globalRulesAckKey: globalRulesAckParam(z),
2928
- }, async ({ path, routeId, methods, roleId, roleName, allowedUserIds, mode, description, isEnabled, globalRulesAckKey }) => {
2929
- assertGlobalRulesAck(globalRulesAckKey);
2930
- if (!path && !routeId)
2931
- throw new Error('Provide path or routeId.');
2932
- if (path && routeId)
2933
- throw new Error('Provide path or routeId, not both.');
2934
- if (roleId && roleName)
2935
- throw new Error('Provide roleId or roleName, not both.');
2936
- if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
2937
- throw new Error('Provide roleId, roleName, or allowedUserIds.');
2938
- }
2939
- const [routes, routePermissions, roles, methodMap, methodIdNameMap] = await Promise.all([
2940
- fetchAll('/enfyra_route?limit=1000'),
2941
- fetchAll('/enfyra_route_permission?limit=1000'),
2942
- fetchAll('/enfyra_role?limit=1000'),
2943
- getMethodMap(),
2944
- getMethodIdNameMap(),
2945
- ]);
2946
- const route = routes.find((item) => (routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)));
2947
- if (!route)
2948
- throw new Error(`Route not found: ${routeId || path}`);
2949
- const role = resolveRoleByNameOrId(roles, { roleId, roleName });
2950
- const scope = {
2951
- roleId: role ? getId(role) : roleId,
2952
- allowedUserIds: allowedUserIds || [],
2953
- };
2954
- const requestedMethods = validateMethodsForRoute(route, methods, methodMap, methodIdNameMap);
2955
- const existing = findRoutePermission(routePermissions, getId(route), scope);
2956
- const existingMethods = existing ? summarizeRoutePermission(existing, methodIdNameMap).methods : [];
2957
- const finalMethods = mergeMethodNames(existingMethods, requestedMethods, mode);
2958
- const methodRefs = resolveMethodIds(methodMap, finalMethods);
2959
- const publicMethods = routePublicMethodNames(route, methodIdNameMap);
2960
- const alreadyPublic = requestedMethods.filter((method) => publicMethods.includes(method));
2961
- let result;
2962
- let action;
2963
- if (existing) {
2964
- action = 'updated';
2965
- const patchBody = {
2966
- isEnabled,
2967
- methods: methodRefs,
2968
- ...(description !== undefined ? { description } : {}),
2969
- };
2970
- result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route_permission/${encodeURIComponent(String(getId(existing)))}`, {
2971
- method: 'PATCH',
2972
- body: JSON.stringify(patchBody),
2973
- });
2974
- }
2975
- else {
2976
- action = 'created';
2977
- const createBody = {
2978
- isEnabled,
2979
- description,
2980
- route: { id: getId(route) },
2981
- methods: methodRefs,
2982
- ...(scope.roleId ? { role: { id: scope.roleId } } : {}),
2983
- ...(scope.allowedUserIds.length ? { allowedUsers: scope.allowedUserIds.map((id) => ({ id })) } : {}),
2984
- };
2985
- result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_permission', {
2986
- method: 'POST',
2987
- body: JSON.stringify(createBody),
2988
- });
2989
- }
2990
- const routeReload = await reloadRoutesResult();
2991
- const saved = firstDataRecord(result);
2992
- const payload = {
2993
- action,
2994
- kind: 'route_access',
2995
- route: {
2996
- id: getId(route),
2997
- path: route.path,
2998
- availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
2999
- publicMethods,
3000
- },
3001
- scope: {
3002
- role: role ? { id: getId(role), name: role.name } : null,
3003
- allowedUserIds: scope.allowedUserIds,
3004
- },
3005
- permission: {
3006
- id: getId(saved) || getId(existing),
3007
- methods: finalMethods,
3008
- alreadyPublic,
3009
- isEnabled,
3010
- },
3011
- result,
3012
- routeReload,
3013
- auditHint: `Call audit_route_access({ path: "${route.path}", ${role ? `roleName: "${role.name}", ` : ''}methods: ${JSON.stringify(requestedMethods)} }) to verify.`,
3014
- };
3015
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
3016
- });
3017
- // Register table tools
3018
- registerTableTools(server, ENFYRA_API_URL, { toolset: MCP_TOOLSET });
3019
- registerPlatformOperationTools(server, ENFYRA_API_URL);
3020
- registerRuntimeZoneTools(server, ENFYRA_API_URL);
3021
- registerOAuthProviderTools(server, ENFYRA_API_URL);
3022
- registerDynamicRepositoryBuilder(server);
3023
- // ============================================================================
3024
- // CACHE & SYSTEM TOOLS
3025
- // ============================================================================
3026
- server.tool('reload_all', 'Reload all caches (metadata, routes, GraphQL)', {
3027
- globalRulesAckKey: globalRulesAckParam(z),
3028
- }, async ({ globalRulesAckKey }) => {
3029
- assertGlobalRulesAck(globalRulesAckKey);
3030
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload', { method: 'POST' });
3031
- return jsonContent({ action: 'reloaded_all', result });
3032
- });
3033
- server.tool('reload_metadata', 'Reload metadata cache only', {
3034
- globalRulesAckKey: globalRulesAckParam(z),
3035
- }, async ({ globalRulesAckKey }) => {
3036
- assertGlobalRulesAck(globalRulesAckKey);
3037
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' });
3038
- return jsonContent({ action: 'reloaded_metadata', result });
3039
- });
3040
- server.tool('reload_routes', 'Reload routes cache only', {
3041
- globalRulesAckKey: globalRulesAckParam(z),
3042
- }, async ({ globalRulesAckKey }) => {
3043
- assertGlobalRulesAck(globalRulesAckKey);
3044
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
3045
- return jsonContent({ action: 'reloaded_routes', result });
3046
- });
3047
- server.tool('reload_graphql', 'Reload GraphQL schema', {
3048
- globalRulesAckKey: globalRulesAckParam(z),
3049
- }, async ({ globalRulesAckKey }) => {
3050
- assertGlobalRulesAck(globalRulesAckKey);
3051
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/graphql', { method: 'POST' });
3052
- return jsonContent({ action: 'reloaded_graphql', result });
3053
- });
3054
- // ============================================================================
3055
- // LOGS TOOLS
3056
- // ============================================================================
3057
- server.tool('get_log_files', 'List available log files and stats', {}, async () => {
3058
- const result = await fetchAPI(ENFYRA_API_URL, '/logs');
3059
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3060
- });
3061
- server.tool('get_log_content', 'Get content of a specific log file', {
3062
- filename: z.string().describe('Log file name'),
3063
- page: z.number().optional().default(1).describe('Page number'),
3064
- pageSize: z.number().optional().default(100).describe('Lines per page'),
3065
- filter: z.string().optional().describe('Text filter'),
3066
- level: z.string().optional().describe('Log level filter (INFO, WARN, ERROR)'),
3067
- }, async ({ filename, page, pageSize, filter, level }) => {
3068
- const queryParams = new URLSearchParams();
3069
- if (page)
3070
- queryParams.set('page', String(page));
3071
- if (pageSize)
3072
- queryParams.set('pageSize', String(pageSize));
3073
- if (filter)
3074
- queryParams.set('filter', filter);
3075
- if (level)
3076
- queryParams.set('level', level);
3077
- const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`);
3078
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3079
- });
3080
- server.tool('tail_log', 'Get last N lines from a log file', {
3081
- filename: z.string().describe('Log file name'),
3082
- lines: z.number().optional().default(50).describe('Number of lines to retrieve'),
3083
- }, async ({ filename, lines }) => {
3084
- const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}/tail?lines=${lines}`);
3085
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3086
- });
3087
- server.tool('search_logs', 'Search for ERROR or WARN logs across recent log files', {
3088
- level: z.enum(['ERROR', 'WARN', 'INFO']).optional().default('ERROR').describe('Log level'),
3089
- keyword: z.string().optional().describe('Keyword to filter logs'),
3090
- limit: z.number().optional().default(50).describe('Max results per level'),
3091
- }, async ({ level, keyword, limit }) => {
3092
- const logFilesResult = await fetchAPI(ENFYRA_API_URL, '/logs');
3093
- const logFiles = logFilesResult.files || [];
3094
- const recentFiles = logFiles.filter((file) => {
3095
- const name = file?.name || '';
3096
- return /^app[.-]/.test(name) || /^error[.-]/.test(name);
3097
- });
3098
- const results = [];
3099
- for (const file of recentFiles.slice(0, 3)) {
3100
- try {
3101
- const contentResult = await fetchAPI(ENFYRA_API_URL, `/logs/${file.name}?level=${level}&pageSize=${limit}`);
3102
- const lines = contentResult.lines || contentResult.data || [];
3103
- const filteredLines = keyword ? lines.filter(l => JSON.stringify(l).toLowerCase().includes(keyword.toLowerCase())) : lines;
3104
- if (filteredLines.length > 0)
3105
- results.push({ file: file.name, level, logs: filteredLines });
3106
- }
3107
- catch (e) { /* skip */ }
3108
- }
3109
- return { content: [{ type: 'text', text: `Found ${results.length} files:\n${JSON.stringify(results, null, 2)}` }] };
3110
- });
3111
- // ============================================================================
3112
- // AUTH & USER TOOLS
3113
- // ============================================================================
3114
- server.tool('get_current_user', 'Get current authenticated user info', {}, async () => {
3115
- const result = await fetchAPI(ENFYRA_API_URL, '/me');
3116
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3117
- });
3118
- server.tool('get_permission_profile', [
3119
- 'Inspect the current token permission profile using the same route-permission model as Enfyra admin UI usePermissions().',
3120
- 'Use this before debugging 403s or before relying on admin helper tools with a non-root API token.',
3121
- 'Reports which MCP tool groups need route permissions such as /admin/script/validate, /admin/test/run, /admin/flow/trigger/:id, and reload endpoints.',
3122
- ].join(' '), {}, async () => {
3123
- const fields = DEFAULT_ME_PERMISSION_FIELDS.join(',');
3124
- const result = await fetchAPI(ENFYRA_API_URL, `/me?fields=${encodeURIComponent(fields)}`);
3125
- const user = firstDataRecord(result);
3126
- return jsonContent(summarizePermissionProfile(user));
3127
- });
3128
- server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
3129
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_role?limit=100');
3130
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3131
- });
3132
- server.tool('login', 'Force authentication to Enfyra and get a new access token', {
3133
- apiToken: z.string().optional().describe('API token for MCP and automation'),
3134
- }, async ({ apiToken }) => {
3135
- const token = apiToken || ENFYRA_API_TOKEN;
3136
- if (token) {
3137
- initAuth(ENFYRA_API_URL, token);
3138
- await exchangeApiToken(ENFYRA_API_URL, token);
3139
- const expiry = getTokenExpiry();
3140
- const expiryLabel = expiry === Infinity ? 'no expiration' : new Date(expiry).toISOString();
3141
- return { content: [{ type: 'text', text: `Authenticated with API token.\nToken expires: ${expiryLabel}` }] };
3142
- }
3143
- throw new Error('ENFYRA_API_TOKEN required');
3144
- });
3145
- // ============================================================================
3146
- // PACKAGE TOOLS
3147
- // ============================================================================
3148
- server.tool('search_npm', 'Search NPM registry for packages. Returns name, version, description for installation.', {
3149
- query: z.string().describe('Package name or search term (e.g., "axios", "node-ssh", "dayjs")'),
3150
- limit: z.number().optional().default(5).describe('Max results (default: 5)'),
3151
- }, async ({ query, limit }) => {
3152
- const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`;
3153
- const response = await fetch(url);
3154
- if (!response.ok)
3155
- throw new Error(`NPM search failed: ${response.statusText}`);
3156
- const data = await response.json();
3157
- const packages = data.objects.map((obj) => ({
3158
- name: obj.package.name,
3159
- version: obj.package.version,
3160
- description: obj.package.description || '',
3161
- }));
3162
- return {
3163
- content: [{
3164
- type: 'text',
3165
- text: JSON.stringify({ packages, total: data.total }, null, 2),
3166
- }],
3167
- };
3168
- });
3169
- server.tool('install_package', [
3170
- 'Install an NPM package on Enfyra. Searches NPM registry for exact version, then creates enfyra_package record.',
3171
- 'Enfyra handles the actual yarn add internally based on type.',
3172
- 'Type "Server" = available in handlers/hooks as $ctx.$pkgs.packageName.',
3173
- 'Type "App" = available in extensions via getPackages().',
3174
- ].join(' '), {
3175
- name: z.string().describe('Exact NPM package name (e.g., "node-ssh", "axios")'),
3176
- type: z.enum(['Server', 'App']).default('Server').describe('Where to install: Server (handlers/hooks) or App (extensions)'),
3177
- version: z.string().optional().describe('Specific version. If omitted, fetches latest from NPM.'),
3178
- globalRulesAckKey: globalRulesAckParam(z),
3179
- }, async ({ name, type, version, globalRulesAckKey }) => {
3180
- assertGlobalRulesAck(globalRulesAckKey);
3181
- // Step 1: Get package info from NPM if version not specified
3182
- let pkgVersion = version;
3183
- let pkgDescription = '';
3184
- if (!pkgVersion) {
3185
- const npmUrl = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(name)}&size=5`;
3186
- const npmResponse = await fetch(npmUrl);
3187
- if (!npmResponse.ok)
3188
- throw new Error(`NPM search failed: ${npmResponse.statusText}`);
3189
- const npmData = await npmResponse.json();
3190
- const exactMatch = npmData.objects.find((obj) => obj.package.name === name);
3191
- if (!exactMatch)
3192
- throw new Error(`Package "${name}" not found on NPM`);
3193
- pkgVersion = exactMatch.package.version;
3194
- pkgDescription = exactMatch.package.description || '';
3195
- }
3196
- // Step 2: Check if already installed (same name AND type)
3197
- const checkFilter = JSON.stringify({ name: { _eq: name }, type: { _eq: type } });
3198
- const existing = await fetchAPI(ENFYRA_API_URL, `/enfyra_package?filter=${encodeURIComponent(checkFilter)}&limit=1`);
3199
- if (existing.data && existing.data.length > 0) {
3200
- return jsonContent({
3201
- action: 'package_already_installed',
3202
- package: {
3203
- name,
3204
- version: existing.data[0].version,
3205
- type: existing.data[0].type,
3206
- },
3207
- record: existing.data[0],
3208
- });
3209
- }
3210
- // Step 3: Get current user for installedBy
3211
- const me = await fetchAPI(ENFYRA_API_URL, '/me');
3212
- const userId = me.data?.[0]?.id || me.data?.[0]?._id;
3213
- if (!userId)
3214
- throw new Error('Cannot get current user ID');
3215
- // Step 4: Install via enfyra_package
3216
- const body = {
3217
- name,
3218
- version: pkgVersion,
3219
- description: pkgDescription,
3220
- type,
3221
- installedBy: { id: userId },
3222
- };
3223
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_package', {
3224
- method: 'POST',
3225
- body: JSON.stringify(body),
3226
- });
3227
- return jsonContent({
3228
- action: 'package_installed',
3229
- package: { name, version: pkgVersion, type },
3230
- result,
3231
- });
3232
- });
3233
- registerToolCatalogTools(server, toolsetState, {
3234
- resolveAvailability: resolveCatalogToolAvailability,
3235
- });
3236
- registerWorkflowToolPack(server, toolsetState);
3237
- // ============================================================================
3238
- // MAIN
3239
- // ============================================================================
3240
- async function main() {
3241
- console.error('Starting Enfyra MCP Server...');
3242
- console.error(`API URL: ${ENFYRA_API_URL}`);
3243
- console.error(`Auth: ${ENFYRA_API_TOKEN ? 'API token configured' : 'Not configured'}`);
3244
- const transport = new StdioServerTransport();
3245
- await server.connect(transport);
3246
- startRuntimeCacheSocket(ENFYRA_API_URL);
3247
- console.error('Enfyra MCP Server running on stdio');
3248
- }
3249
- main().catch((error) => {
1
+ import { runEnfyraMcpServer } from './lib/enfyra-mcp-server.js';
2
+ runEnfyraMcpServer().catch((error) => {
3250
3
  console.error('Fatal error:', error);
3251
4
  process.exit(1);
3252
5
  });