@enfyra/mcp-server 0.1.62 → 0.1.64

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 (52) hide show
  1. package/README.md +1 -0
  2. package/dist/lib/dynamic-endpoint-contract.d.ts +12 -0
  3. package/dist/lib/dynamic-endpoint-contract.js +148 -0
  4. package/dist/lib/dynamic-endpoint-contract.js.map +1 -0
  5. package/dist/lib/dynamic-repository-builder.d.ts +7 -0
  6. package/dist/lib/dynamic-repository-builder.js +8 -0
  7. package/dist/lib/dynamic-repository-builder.js.map +1 -1
  8. package/dist/lib/extension-search-tools.js +2 -1
  9. package/dist/lib/extension-search-tools.js.map +1 -1
  10. package/dist/lib/extension-sfc-analyzer.d.ts +4 -0
  11. package/dist/lib/extension-sfc-analyzer.js +137 -0
  12. package/dist/lib/extension-sfc-analyzer.js.map +1 -0
  13. package/dist/lib/mcp-examples.js +117 -8
  14. package/dist/lib/mcp-examples.js.map +1 -1
  15. package/dist/lib/mcp-instructions.js +5 -15
  16. package/dist/lib/mcp-instructions.js.map +1 -1
  17. package/dist/lib/mcp-usage-telemetry.js +41 -2
  18. package/dist/lib/mcp-usage-telemetry.js.map +1 -1
  19. package/dist/lib/mutation-guards.d.ts +1 -0
  20. package/dist/lib/mutation-guards.js +42 -0
  21. package/dist/lib/mutation-guards.js.map +1 -1
  22. package/dist/lib/platform-operation-tools.d.ts +160 -3
  23. package/dist/lib/platform-operation-tools.js +454 -95
  24. package/dist/lib/platform-operation-tools.js.map +1 -1
  25. package/dist/lib/required-knowledge.d.ts +9 -2
  26. package/dist/lib/required-knowledge.js +60 -17
  27. package/dist/lib/required-knowledge.js.map +1 -1
  28. package/dist/lib/response-format.js +39 -25
  29. package/dist/lib/response-format.js.map +1 -1
  30. package/dist/lib/runtime-zone-tools.js +4 -3
  31. package/dist/lib/runtime-zone-tools.js.map +1 -1
  32. package/dist/lib/session-safety.d.ts +9 -0
  33. package/dist/lib/session-safety.js +89 -0
  34. package/dist/lib/session-safety.js.map +1 -0
  35. package/dist/lib/source-artifacts.js +3 -1
  36. package/dist/lib/source-artifacts.js.map +1 -1
  37. package/dist/lib/table-tools.d.ts +9 -1
  38. package/dist/lib/table-tools.js +69 -9
  39. package/dist/lib/table-tools.js.map +1 -1
  40. package/dist/lib/tool-input-normalization.d.ts +4 -0
  41. package/dist/lib/tool-input-normalization.js +35 -0
  42. package/dist/lib/tool-input-normalization.js.map +1 -0
  43. package/dist/lib/tool-routing.d.ts +6 -2
  44. package/dist/lib/tool-routing.js +52 -16
  45. package/dist/lib/tool-routing.js.map +1 -1
  46. package/dist/lib/toolset-filter.d.ts +7 -3
  47. package/dist/lib/toolset-filter.js +144 -86
  48. package/dist/lib/toolset-filter.js.map +1 -1
  49. package/dist/lib/types.d.ts +62 -0
  50. package/dist/mcp-server-entry.js +79 -23
  51. package/dist/mcp-server-entry.js.map +1 -1
  52. package/package.json +4 -1
@@ -64,19 +64,21 @@ import { getSupportedColumnTypesFromMetadata, registerTableTools } from './lib/t
64
64
  import { registerPlatformOperationTools, validateExtensionCode } from './lib/platform-operation-tools.js';
65
65
  import { registerRuntimeZoneTools } from './lib/runtime-zone-tools.js';
66
66
  import { registerDynamicRepositoryBuilder } from './lib/dynamic-repository-builder.js';
67
- import { parseRecordBatchData, parseRecordData, prepareRecordBatchMutation, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
68
- import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAckIf, assertGlobalRulesAck, buildRequiredKnowledgePayload, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './lib/required-knowledge.js';
67
+ import { assertCreateHandlerRouteBoundary } from './lib/dynamic-endpoint-contract.js';
68
+ import { assertGenericRecordMutationAllowed, parseRecordBatchData, parseRecordData, prepareRecordBatchMutation, prepareRecordMutation, validatePortableScriptSource, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
69
+ import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAckIf, assertGlobalRulesAck, acknowledgeRequiredKnowledge, buildRequiredKnowledgePayload, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './lib/required-knowledge.js';
69
70
  import { validateMainTableRoutePath } from './lib/route-guards.js';
70
71
  import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
71
72
  import { startMcpUsageTelemetry } from './lib/mcp-usage-telemetry.js';
72
73
  import { startRuntimeCacheSocket } from './lib/runtime-cache-socket.js';
73
74
  import { executeSequentialBatch } from './lib/sequential-batch.js';
74
75
  import { compactSourceFields, writeSourceArtifact } from './lib/source-artifacts.js';
75
- import { installToolsetFilter, normalizeMcpToolset, summarizeToolsetForInstructions } from './lib/toolset-filter.js';
76
+ import { installToolsetFilter, normalizeMcpProfile, normalizeMcpToolset, summarizeToolsetForInstructions } from './lib/toolset-filter.js';
76
77
  import { findRoutePermission, mergeMethodNames, normalizeMethodNames, resolveRoleByNameOrId, routeAvailableMethodNames, routePublicMethodNames, summarizeRouteAccess, summarizeRoutePermission, validateMethodsForRoute, } from './lib/route-permission-tools.js';
77
78
  // Initialize auth module
78
79
  initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
79
80
  const MCP_TOOLSET = normalizeMcpToolset(process.env.ENFYRA_MCP_TOOLSET);
81
+ const MCP_PROFILE = normalizeMcpProfile(process.env.ENFYRA_MCP_PROFILE);
80
82
  const CAPABILITY_AREAS = [
81
83
  {
82
84
  area: 'Schema and metadata',
@@ -808,12 +810,12 @@ const server = new McpServer({
808
810
  version: '1.0.0',
809
811
  }, {
810
812
  instructions: buildMcpServerInstructions(ENFYRA_API_URL, {
811
- toolsetSummary: summarizeToolsetForInstructions(MCP_TOOLSET),
813
+ toolsetSummary: summarizeToolsetForInstructions(MCP_TOOLSET, MCP_PROFILE),
812
814
  }),
813
815
  });
814
816
  installColumnarToolFormatter(server);
815
- installToolsetFilter(server, MCP_TOOLSET);
816
- startMcpUsageTelemetry(ENFYRA_API_URL, MCP_TOOLSET);
817
+ installToolsetFilter(server, MCP_TOOLSET, MCP_PROFILE);
818
+ startMcpUsageTelemetry(ENFYRA_API_URL, `${MCP_TOOLSET}:${MCP_PROFILE}`);
817
819
  // ============================================================================
818
820
  // METADATA TOOLS
819
821
  // ============================================================================
@@ -822,8 +824,12 @@ server.tool('get_enfyra_required_knowledge', [
822
824
  '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.',
823
825
  '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.',
824
826
  ].join(' '), {
825
- scope: z.enum(['schema', 'dynamic-code', 'extension', 'flow']).optional().describe('Limit knowledge to one domain. Omit to load all rules.'),
826
- }, async ({ scope }) => jsonContent(buildRequiredKnowledgePayload(scope)));
827
+ 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.'),
828
+ }, async ({ scope }) => {
829
+ const payload = buildRequiredKnowledgePayload(scope);
830
+ const sessionAcknowledgement = acknowledgeRequiredKnowledge(scope);
831
+ return jsonContent({ ...payload, sessionAcknowledgement });
832
+ });
827
833
  server.tool('get_all_metadata', 'Get a lightweight table catalog. Use get_table_metadata or inspect_table to fetch one table schema.', {
828
834
  includeFull: z.boolean().optional().default(false).describe('Fetch per-table metadata for the selected catalog entries. Default false keeps discovery lightweight.'),
829
835
  search: z.string().optional().describe('Optional table-name/alias substring filter.'),
@@ -898,7 +904,7 @@ server.tool('discover_enfyra_workflows', [
898
904
  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.'),
899
905
  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.'),
900
906
  limit: z.number().int().positive().max(10).optional().default(5).describe('Maximum workflows to return.'),
901
- }, async (input) => jsonContent(discoverWorkflowRoutes(input)));
907
+ }, async (input) => jsonContent(discoverWorkflowRoutes(input, MCP_PROFILE)));
902
908
  server.tool('discover_enfyra_system', [
903
909
  'Call this first when you need to understand the live Enfyra instance.',
904
910
  'Returns a concise capability map from live metadata/routes/method rows, including schema management, REST route behavior, GraphQL enablement, and relation handling.',
@@ -971,7 +977,7 @@ server.tool('discover_enfyra_system', [
971
977
  adminTesting: {
972
978
  runAdminTest: 'run_admin_test wraps POST /admin/test/run for flow_step, websocket_event, and websocket_connection scripts.',
973
979
  testFlowStep: 'test_flow_step also wraps POST /admin/test/run with kind=flow_step.',
974
- triggerFlow: 'trigger_flow wraps POST /admin/flow/trigger/:id and enqueues a flow execution.',
980
+ triggerFlow: 'trigger_flow resolves a saved enabled flow, then wraps POST /admin/flow/trigger/:id. Use test_flow_step for disabled flows.',
975
981
  },
976
982
  graphql: {
977
983
  endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
@@ -1203,6 +1209,12 @@ server.tool('discover_script_contexts', [
1203
1209
  '@STATUS': '$ctx.$statusCode',
1204
1210
  '@ERROR': '$ctx.$error',
1205
1211
  },
1212
+ 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.',
1213
+ socket: {
1214
+ contract: '@SOCKET has no generic emit() method.',
1215
+ boundWebsocketMethods: ['reply(event, data)', 'join(room)', 'leave(room)', 'emitToCurrentRoom(room, event, data)', 'broadcastToRoom(room, event, data)', 'disconnect()'],
1216
+ globalMethods: ['emitToGateway(path, event, data)', 'emitToRoom(path, room, event, data)', 'emitToUser(userId, event, data)', 'broadcast(event, data)', 'roomSize(room)'],
1217
+ },
1206
1218
  flowMacros: {
1207
1219
  '@FLOW': '$ctx.$flow',
1208
1220
  '@FLOW_PAYLOAD': '$ctx.$flow.$payload',
@@ -1217,9 +1229,9 @@ server.tool('discover_script_contexts', [
1217
1229
  },
1218
1230
  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.',
1219
1231
  helpers: {
1220
- 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.',
1232
+ 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.',
1221
1233
  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.',
1222
- crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Use generateSshKeyPair for SSH key material. Do not use legacy $ctx.$helpers.$ssh.',
1234
+ 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.',
1223
1235
  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.',
1224
1236
  },
1225
1237
  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.',
@@ -1499,6 +1511,7 @@ server.tool('create_records', 'Create one or more route-backed records. Always p
1499
1511
  }, async ({ tableName, records, queryParams, maxRecords, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1500
1512
  assertGlobalRulesAck(globalRulesAckKey);
1501
1513
  validateTableName(tableName);
1514
+ assertGenericRecordMutationAllowed('create', tableName);
1502
1515
  const parsedRecords = parseRecordBatchData(records);
1503
1516
  if (parsedRecords.length > maxRecords) {
1504
1517
  throw new Error(`create_records received ${parsedRecords.length} records, above maxRecords=${maxRecords}. Split the batch deliberately.`);
@@ -1560,6 +1573,7 @@ server.tool('update_records', 'Update one or more records in one MCP call. Pass
1560
1573
  }, async ({ tableName, items, maxItems, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1561
1574
  assertGlobalRulesAck(globalRulesAckKey);
1562
1575
  validateTableName(tableName);
1576
+ assertGenericRecordMutationAllowed('update', tableName);
1563
1577
  const parsedItems = parseBulkItemsArg('items', items);
1564
1578
  assertMaxBulkItems('update_records', parsedItems, maxItems);
1565
1579
  assertNoDuplicateBulkIds('update_records', parsedItems);
@@ -1747,6 +1761,7 @@ server.tool('delete_records', 'Delete one or more route-backed records in one MC
1747
1761
  globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1748
1762
  }, async ({ tableName, items, maxItems, confirm, skipNotFound, globalRulesAckKey }) => {
1749
1763
  validateTableName(tableName);
1764
+ assertGenericRecordMutationAllowed('delete', tableName);
1750
1765
  const parsedItems = parseBulkItemsArg('items', items);
1751
1766
  assertMaxBulkItems('delete_records', parsedItems, maxItems);
1752
1767
  assertNoDuplicateBulkIds('delete_records', parsedItems);
@@ -1957,29 +1972,45 @@ server.tool('delete_method', 'Preview or delete a enfyra_method record. Only del
1957
1972
  server.tool('run_admin_test', [
1958
1973
  'Run an Enfyra admin test without saving metadata. Wraps POST /admin/test/run.',
1959
1974
  'Kinds: script, flow_step, websocket_event, websocket_connection. Use this to validate dynamic script, flow, or websocket behavior before creating records.',
1975
+ '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.',
1960
1976
  ].join(' '), {
1961
1977
  kind: z.enum(['script', 'flow_step', 'websocket_event', 'websocket_connection']).describe('Admin test kind'),
1962
- body: z.string().describe('JSON body for the test. Include script and optional context for script; type/config for flow_step; or script/gatewayPath/eventName/payload for websocket tests. Do not include kind; the tool adds it.'),
1978
+ body: z.string().describe('JSON body for the test. 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.'),
1963
1979
  }, async ({ kind, body }) => {
1964
1980
  const parsed = body ? JSON.parse(body) : {};
1981
+ const sourceCode = kind === 'flow_step'
1982
+ ? parsed?.config?.sourceCode ?? parsed?.config?.code
1983
+ : parsed?.script ?? parsed?.sourceCode;
1984
+ if (typeof sourceCode === 'string')
1985
+ validatePortableScriptSource(sourceCode);
1965
1986
  const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
1966
1987
  method: 'POST',
1967
1988
  body: JSON.stringify({ ...parsed, kind }),
1968
1989
  });
1969
1990
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1970
1991
  });
1971
- server.tool('test_flow_step', 'Test a single flow step without saving it. Wraps POST /admin/test/run with kind=flow_step.', {
1992
+ 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.', {
1972
1993
  type: z.enum(['script', 'condition', 'query', 'create', 'update', 'delete', 'http', 'trigger_flow', 'sleep', 'log']).describe('Flow step type'),
1973
1994
  config: z.string().describe('Step config as JSON string'),
1974
1995
  timeout: z.number().optional().describe('Timeout in ms'),
1975
1996
  key: z.string().optional().describe('Optional step key for mock flow context'),
1976
- mockFlow: z.string().optional().describe('Optional mockFlow JSON object'),
1977
- }, async ({ type, config, timeout, key, mockFlow }) => {
1997
+ 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.'),
1998
+ mockFlow: z.string().optional().describe('Optional advanced mockFlow JSON object for $last/$meta or other flow context. Use payload for @FLOW_PAYLOAD.'),
1999
+ }, async ({ type, config, timeout, key, payload, mockFlow }) => {
2000
+ const parsedConfig = JSON.parse(config);
2001
+ const sourceCode = parsedConfig?.sourceCode ?? parsedConfig?.code;
2002
+ if (typeof sourceCode === 'string')
2003
+ validatePortableScriptSource(sourceCode);
2004
+ const parsedPayload = typeof payload === 'string' ? JSON.parse(payload) : payload;
2005
+ if (parsedPayload !== undefined && (!parsedPayload || typeof parsedPayload !== 'object' || Array.isArray(parsedPayload))) {
2006
+ throw new Error('payload must be a JSON object.');
2007
+ }
1978
2008
  const body = {
1979
2009
  type,
1980
- config: JSON.parse(config),
2010
+ config: parsedConfig,
1981
2011
  ...(timeout ? { timeout } : {}),
1982
2012
  ...(key ? { key } : {}),
2013
+ ...(parsedPayload !== undefined ? { payload: parsedPayload } : {}),
1983
2014
  ...(mockFlow ? { mockFlow: JSON.parse(mockFlow) } : {}),
1984
2015
  };
1985
2016
  const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
@@ -1988,11 +2019,23 @@ server.tool('test_flow_step', 'Test a single flow step without saving it. Wraps
1988
2019
  });
1989
2020
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1990
2021
  });
1991
- server.tool('trigger_flow', 'Trigger a saved flow by id or name. Wraps POST /admin/flow/trigger/:id.', {
2022
+ 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.', {
1992
2023
  flowIdOrName: z.union([z.string(), z.number()]).describe('Flow id or name accepted by FlowService.trigger'),
1993
2024
  payload: z.string().optional().describe('Payload JSON object. Default {}.'),
1994
2025
  }, async ({ flowIdOrName, payload }) => {
1995
- const result = await fetchAPI(ENFYRA_API_URL, `/admin/flow/trigger/${encodeURIComponent(String(flowIdOrName))}`, {
2026
+ const rawIdentifier = String(flowIdOrName);
2027
+ const filter = typeof flowIdOrName === 'number' || /^\d+$/.test(rawIdentifier)
2028
+ ? { id: { _eq: flowIdOrName } }
2029
+ : { name: { _eq: rawIdentifier } };
2030
+ const lookup = await fetchAPI(ENFYRA_API_URL, `/enfyra_flow?filter=${encodeURIComponent(JSON.stringify(filter))}&limit=1&fields=id,_id,name,isEnabled`);
2031
+ const flow = unwrapData(lookup)[0];
2032
+ if (!flow)
2033
+ throw new Error(`Flow not found: ${rawIdentifier}`);
2034
+ if (flow.isEnabled === false) {
2035
+ 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.`);
2036
+ }
2037
+ const flowId = flow.id ?? flow._id;
2038
+ const result = await fetchAPI(ENFYRA_API_URL, `/admin/flow/trigger/${encodeURIComponent(String(flowId))}`, {
1996
2039
  method: 'POST',
1997
2040
  body: JSON.stringify({ payload: payload ? JSON.parse(payload) : {} }),
1998
2041
  });
@@ -2508,7 +2551,7 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Compl
2508
2551
  server.tool('create_route', [
2509
2552
  '**Use this when the user wants a new REST API route or path** — not `create_tables`. Custom routes must omit `mainTableId`.',
2510
2553
  '`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.',
2511
- 'Do NOT create a new enfyra_table only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
2554
+ '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`.',
2512
2555
  '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.',
2513
2556
  'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
2514
2557
  'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
@@ -2561,7 +2604,8 @@ server.tool('create_route', [
2561
2604
  });
2562
2605
  server.tool('create_handler', [
2563
2606
  'Create a handler for a route+method. One handler per (route, method) pair.',
2564
- 'Attach to the route the user cares about (`get_all_routes`): typically a path from `create_route`, not a spurious table created only for handlers.',
2607
+ '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.',
2608
+ '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.',
2565
2609
  'Use sourceCode, not logic/name. Enfyra compiles sourceCode into compiledCode; do not send compiledCode.',
2566
2610
  'Handler code runs inside a sandbox with $ctx. Use macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503, @SOCKET, @PKGS, @LOGS, @SHARE.',
2567
2611
  '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.',
@@ -2578,9 +2622,20 @@ server.tool('create_handler', [
2578
2622
  timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
2579
2623
  globalRulesAckKey: globalRulesAckParam(z),
2580
2624
  knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2581
- }, async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout, globalRulesAckKey, knowledgeAckKey }) => {
2625
+ 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.'),
2626
+ }, async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout, globalRulesAckKey, knowledgeAckKey, allowCanonicalRoute }) => {
2582
2627
  assertGlobalRulesAck(globalRulesAckKey);
2583
2628
  assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2629
+ const routeQuery = new URLSearchParams({
2630
+ filter: JSON.stringify({ id: { _eq: routeId } }),
2631
+ fields: 'id,path,mainTable.id,mainTable.name',
2632
+ limit: '1',
2633
+ });
2634
+ const routeResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${routeQuery.toString()}`);
2635
+ const targetRoute = unwrapData(routeResult)[0];
2636
+ if (!targetRoute)
2637
+ throw new Error(`Route not found: ${String(routeId)}`);
2638
+ assertCreateHandlerRouteBoundary(targetRoute, sourceCode, allowCanonicalRoute);
2584
2639
  const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
2585
2640
  if (methodNames.length === 0)
2586
2641
  throw new Error('Provide method or methods');
@@ -2614,6 +2669,7 @@ server.tool('create_handler', [
2614
2669
  return { content: [{ type: 'text', text: JSON.stringify({
2615
2670
  action: 'created',
2616
2671
  handlers: results,
2672
+ canonicalRouteAcknowledged: Boolean(targetRoute?.mainTable && allowCanonicalRoute),
2617
2673
  scriptValidation,
2618
2674
  routeReload,
2619
2675
  detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
@@ -2880,7 +2936,7 @@ server.tool('ensure_route_access', [
2880
2936
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2881
2937
  });
2882
2938
  // Register table tools
2883
- registerTableTools(server, ENFYRA_API_URL);
2939
+ registerTableTools(server, ENFYRA_API_URL, { toolset: MCP_TOOLSET });
2884
2940
  registerPlatformOperationTools(server, ENFYRA_API_URL);
2885
2941
  registerRuntimeZoneTools(server, ENFYRA_API_URL);
2886
2942
  registerDynamicRepositoryBuilder(server);