@enfyra/mcp-server 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -244,15 +244,20 @@ Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate
244
244
  - files and storage
245
245
  - Enfyra admin extensions
246
246
 
247
+ Use `get_enfyra_required_knowledge` before asking an LLM to save dynamic server code or Enfyra extension code. It returns short required contracts plus acknowledgement keys that code-writing tools verify before saving.
248
+
247
249
  ## Runtime Safety
248
250
 
249
251
  The MCP server includes safety guards for LLM callers:
250
252
 
251
253
  - Generic record mutations validate fields against live metadata.
254
+ - Code-writing tools require `get_enfyra_required_knowledge` acknowledgement before saving dynamic scripts or extension code. Discovery, validation, and preview tools remain available without the acknowledgement so agents can read and plan first.
252
255
  - Script-backed records validate `sourceCode` through `/admin/script/validate` before saving.
253
256
  - `validate_dynamic_script` checks handler, hook, flow, websocket, GraphQL, and bootstrap script source without saving.
254
257
  - `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
258
+ - Dynamic script guidance distinguishes secure repositories (`@REPOS.main`, `@REPOS.secure.<table>`) from trusted internal repositories (`@REPOS.<table>`), and tells agents not to return raw trusted records to users.
255
259
  - `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
260
+ - Long source/code values in read responses are written to `/tmp/enfyra-mcp-sources` and returned as length/hash/preview/tmpFile metadata so LLM callers can inspect full source from the file path without truncating tool output.
256
261
  - JSON responses include `compressionStats` with estimated token savings. Arrays of objects are converted to columnar form only when the compact shape is smaller than raw JSON.
257
262
  - Relation tools reject physical FK/junction names and resolve table ids from exact table names or aliases before schema mutation.
258
263
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
@@ -295,7 +300,7 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
295
300
 
296
301
  ## Tool Summary
297
302
 
298
- The MCP server exposes tools for metadata discovery, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, and `get_enfyra_api_context`.
303
+ The MCP server exposes tools for metadata discovery, required knowledge, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, `get_enfyra_api_context`, and `get_enfyra_required_knowledge`.
299
304
 
300
305
  Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods` and `private_route_methods` for that access boundary.
301
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -894,7 +894,8 @@ query_table({
894
894
  notes: [
895
895
  'Use _max(relation.field) for latest-child ordering, _min(relation.field) for earliest-child ordering, and _count(relation) for child-count ordering.',
896
896
  'Aggregate sort helpers only work on direct one-to-many or many-to-many list relations.',
897
- 'The aggregate field must be a real non-encrypted scalar field on the related table.',
897
+ 'The aggregate field must be a real published, non-encrypted scalar field on the related table for user-facing APIs.',
898
+ 'Do not use _max, _min, or _count on private relations or unpublished fields unless the endpoint intentionally exposes that fact.',
898
899
  'Do not use raw sort=-messages.createdAt for parent ordering; it is ambiguous and rejected.',
899
900
  'deep.messages.sort only orders the loaded message rows inside each ticket, so keep parent sort and child pagination as separate concerns.',
900
901
  ],
@@ -943,6 +944,7 @@ const found = await #integrations.find({
943
944
  routeId: "<route_id>",
944
945
  method: "POST",
945
946
  scriptLanguage: "javascript",
947
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
946
948
  sourceCode: \`const email = @BODY.email
947
949
  if (!email) @THROW400("Email is required")
948
950
 
@@ -950,6 +952,7 @@ return { ok: true, email }\`
950
952
  })`,
951
953
  notes: [
952
954
  'Use sourceCode, not logic. The server generates compiledCode.',
955
+ 'Call get_enfyra_required_knowledge before saving dynamic code and pass dynamicCodeAckKey as knowledgeAckKey.',
953
956
  'Use method for one handler, or methods only when the same sourceCode should be saved for multiple methods.',
954
957
  'Do not pass name to enfyra_route_handler; one handler is identified by route + method.',
955
958
  ],
@@ -1030,6 +1033,7 @@ const scope = {
1030
1033
  name: "strip_email_verification_fields",
1031
1034
  methods: ["PATCH"],
1032
1035
  priority: -10,
1036
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
1033
1037
  code: \`delete @BODY.emailVerifiedAt
1034
1038
  delete @BODY.emailVerificationStatus
1035
1039
  delete @BODY.emailVerificationSentAt\`
@@ -1047,6 +1051,7 @@ delete @BODY.emailVerificationSentAt\`
1047
1051
  name: "shape_display_title",
1048
1052
  methods: ["GET"],
1049
1053
  priority: 0,
1054
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
1050
1055
  code: \`if (@ERROR) {
1051
1056
  @LOGS("Request failed", @ERROR.message)
1052
1057
  return
@@ -1320,7 +1325,7 @@ const socket = io("/chat", {
1320
1325
  code: `const conversationId = @BODY.conversationId
1321
1326
  if (!conversationId) @THROW400("conversationId is required")
1322
1327
 
1323
- const membership = await @REPOS.chat_conversation_member.find({
1328
+ const membership = await @REPOS.secure.chat_conversation_member.find({
1324
1329
  filter: {
1325
1330
  conversation: { id: { _eq: conversationId } },
1326
1331
  member: { id: { _eq: @USER.id } }
@@ -1336,6 +1341,7 @@ if (!membership.data[0]) @THROW403("Not a conversation member")
1336
1341
  'Join conversation rooms, not member-id rooms.',
1337
1342
  'conversationId is a request/room identifier; DB filters still use the relation property conversation.',
1338
1343
  'Check membership server-side; do not trust the client.',
1344
+ 'Use @REPOS.secure.<table> for explicit table access in user-facing websocket scripts.',
1339
1345
  ],
1340
1346
  },
1341
1347
  {
@@ -1343,7 +1349,7 @@ if (!membership.data[0]) @THROW403("Not a conversation member")
1343
1349
  code: `const { conversationId, text, clientId } = @BODY
1344
1350
  if (!conversationId || !text) @THROW400("conversationId and text are required")
1345
1351
 
1346
- const membership = await @REPOS.chat_conversation_member.find({
1352
+ const membership = await @REPOS.secure.chat_conversation_member.find({
1347
1353
  filter: {
1348
1354
  conversation: { id: { _eq: conversationId } },
1349
1355
  member: { id: { _eq: @USER.id } }
@@ -1352,7 +1358,7 @@ const membership = await @REPOS.chat_conversation_member.find({
1352
1358
  })
1353
1359
  if (!membership.data[0]) @THROW403("Not a conversation member")
1354
1360
 
1355
- const created = await @REPOS.chat_message.create({
1361
+ const created = await @REPOS.secure.chat_message.create({
1356
1362
  data: {
1357
1363
  conversation: { id: conversationId },
1358
1364
  sender: { id: @USER.id },
@@ -1363,7 +1369,7 @@ const created = await @REPOS.chat_message.create({
1363
1369
 
1364
1370
  const message = created.data?.[0] ?? null
1365
1371
  if (message?.id) {
1366
- await @REPOS.chat_conversation.update({
1372
+ await @REPOS.secure.chat_conversation.update({
1367
1373
  id: conversationId,
1368
1374
  data: { lastMessage: { id: message.id }, updatedAt: message.createdAt || new Date().toISOString() }
1369
1375
  })
@@ -1378,6 +1384,7 @@ return { ok: true, message }`,
1378
1384
  'Do not ask the client for senderId. The sender relation is derived from @USER.id.',
1379
1385
  'conversationId is accepted only as the room/business identifier; persistence uses relation properties conversation and sender, not physical FK fields.',
1380
1386
  'Event scripts should explicitly emit replies/broadcasts.',
1387
+ 'Use @REPOS.secure.<table> for explicit table access in user-facing websocket scripts; trusted @REPOS.<table> is only for internal/admin logic that will sanitize output.',
1381
1388
  ],
1382
1389
  },
1383
1390
  ],
@@ -1551,7 +1558,8 @@ ensure_page_extension({
1551
1558
  description: "Reports dashboard",
1552
1559
  menuId: "<created-menu-id>",
1553
1560
  code: "<template><section class=\\"min-h-full w-full space-y-4\\"><div class=\\"grid gap-4 md:grid-cols-2 xl:grid-cols-3\\"><article class=\\"eapp-surface-card p-4\\"><div class=\\"flex items-start justify-between gap-3\\"><div><p class=\\"text-sm font-medium eapp-text-tertiary\\">Total</p><p class=\\"mt-2 text-2xl font-semibold eapp-text-primary\\">0</p></div><span class=\\"eapp-primary-soft eapp-icon-tile\\"><span class=\\"eapp-primary-text\\">◆</span></span></div><div class=\\"mt-3 h-1.5 overflow-hidden eapp-radius-pill eapp-surface-muted\\"><div class=\\"eapp-primary-solid h-full w-1/2\\"></div></div></article><article class=\\"eapp-primary-surface eapp-radius-panel border p-4\\"><p class=\\"text-sm font-semibold eapp-text-primary\\">Selected report</p><p class=\\"mt-1 text-sm eapp-text-tertiary\\">Only selected/current identity blocks use identity surface.</p></article></div></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); const { register: registerHeaderActions } = useHeaderActionRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'none', variant: 'minimal' }); registerHeaderActions([{ id: 'refresh-reports', label: 'Refresh', icon: 'lucide:refresh-cw', color: 'neutral', variant: 'outline', onClick: () => {}, order: 80 }])</script>",
1554
- isEnabled: true
1561
+ isEnabled: true,
1562
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1555
1563
  })`,
1556
1564
  notes: [
1557
1565
  'Menu provides navigation; extension provides content.',
@@ -1559,6 +1567,7 @@ ensure_page_extension({
1559
1567
  'Sensitive admin menus should include a permission condition at creation time.',
1560
1568
  'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1561
1569
  'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI.',
1570
+ 'Call get_enfyra_required_knowledge before saving extension code and pass extensionAckKey as extensionKnowledgeAckKey.',
1562
1571
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1563
1572
  'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
1564
1573
  'Do not put ordinary KPI cards in PageHeader.stats; render metrics in the extension body.',
@@ -1623,7 +1632,8 @@ ensure_widget_extension({
1623
1632
  name: "ReportStatusWidget",
1624
1633
  description: "Report status summary cards",
1625
1634
  code: reportStatusWidgetCode,
1626
- isEnabled: true
1635
+ isEnabled: true,
1636
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1627
1637
  })
1628
1638
 
1629
1639
  // Read the created widget record id, then embed it from the page extension.
@@ -1631,7 +1641,8 @@ ensure_page_extension({
1631
1641
  name: "ReportsPage",
1632
1642
  menuId: "<reports-menu-id>",
1633
1643
  code: "<template><section class=\\"min-h-full w-full space-y-4\\"><Widget :id=\\"<report-status-widget-id>\\" :total=\\"totalReports\\" :rows=\\"reportRows\\" :open-details=\\"openReportDetails\\" @refresh=\\"refresh\\" /><Widget :id=\\"<report-table-widget-id>\\" :rows=\\"reportRows\\" @refresh=\\"refresh\\" /></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'none', variant: 'minimal' }); const totalReports = ref(0); const reportRows = ref([]); function refresh() {} function openReportDetails(row) { navigateTo('/data/report?filter=' + encodeURIComponent(JSON.stringify({ id: { _eq: row.id } }))) }</script>",
1634
- isEnabled: true
1644
+ isEnabled: true,
1645
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1635
1646
  })`,
1636
1647
  notes: [
1637
1648
  'Use widgets for bulky or reusable sections such as operation panels, timelines, tables, sidebars, and status cards.',
@@ -1707,7 +1718,8 @@ ensure_global_extension({
1707
1718
  name: "NotificationBellGlobal",
1708
1719
  description: "Registers the app-wide notification bell in the account panel",
1709
1720
  code: notificationBellCode,
1710
- isEnabled: true
1721
+ isEnabled: true,
1722
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1711
1723
  })`,
1712
1724
  notes: [
1713
1725
  'Global extensions are mounted invisibly by Enfyra admin UI during layout init; do not create a menu and do not embed them with Widget.',
@@ -2030,6 +2042,7 @@ onMounted(() => Promise.all([flowStats.execute(), orderStats.execute()]))
2030
2042
  'Aggregate keys must be real fields or relations.',
2031
2043
  'Read results from response.meta.aggregate.',
2032
2044
  'Use top-level filter for time windows and cross-field conditions.',
2045
+ 'Only aggregate fields and relations that the dashboard is allowed to expose; aggregate values can reveal hidden data even when rows omit that field.',
2033
2046
  'sum/avg require numeric fields; amount_usd must be a real float/numeric SQL column, not metadata-only float over a varchar physical column.',
2034
2047
  ],
2035
2048
  },
@@ -27,25 +27,30 @@ export function buildMcpServerInstructions(apiBaseUrl) {
27
27
  '- For a quick target/base sanity check, call `get_enfyra_api_context`; do not call broad discovery just to confirm which instance this MCP is connected to.',
28
28
  '- Discover before deciding. For architecture/capability questions call `discover_enfyra_system`; for DB/pk/runtime/cache context call `discover_runtime_context`; for filters/deep/sort/relation query shape call `discover_query_capabilities`. Run broad discovery tools sequentially, not in parallel.',
29
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
- '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category. Before extension UI work, call `get_extension_theme_contract` and follow the app-shell/theme/security contract. For the exact class name or Nuxt UI color mapping, call `get_theme_class_reference`; this is the authoritative theme & color contract and must be followed exactly - never use raw CSS variables, Tailwind palette accents, or concrete palettes like color="violet".',
30
+ '- Load examples only when needed. Use `get_enfyra_examples` by category. Before extension UI, call `get_extension_theme_contract`; call `get_theme_class_reference` for exact eapp/Nuxt UI theme classes.',
31
31
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
+ '- Before writing dynamic server or extension code, call `get_enfyra_required_knowledge` and pass the matching acknowledgement key into write tools.',
32
33
  '- With non-root API tokens, call `get_permission_profile` before relying on admin helper tools or when debugging 403s. MCP admin helpers require ordinary route permissions for static admin routes such as `/admin/script/validate`, `/admin/test/run`, `/admin/flow/trigger/:id`, and `/admin/reload/*`.',
33
- '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `enable_route`, `disable_route`, `delete_route`, `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, `choose_flow_step_tool`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
34
+ '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `add_route_methods`, `public_route_methods`, `set_table_graphql`, guard/permission/rule tools, websocket tools, flow tools, and `ensure_page_extension`/menu tools.',
34
35
  '- Before saving standalone dynamic script or extension code, call `validate_dynamic_script` or `validate_extension_code` unless the chosen ensure/update tool already validates the code.',
35
36
  '- For existing script-backed records, use `trace_metadata_usage` then `get_script_source`; edit with `patch_script_source` or `update_script_source` so source is hash-checked and validated.',
36
37
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
37
38
  '',
38
39
  '### Core Contracts',
39
- '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Large arrays of objects may be encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }` only when that is smaller than raw JSON; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings and includes whether compression was applied; use it only when the user asks about savings.',
40
+ '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. If rows are columnar, read values by matching `columns[index]` to `rows[n][index]`; do not guess row keys.',
40
41
  '- `query_table`, `get_all_routes`, and `get_all_tables` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
41
42
  '- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
42
43
  '- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
43
- '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
44
+ '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `@REPOS.secure.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
45
+ '- Dynamic repositories have two trust paths. Use secure `@REPOS.main` or `@REPOS.secure.<table>` for user-facing data. `@REPOS.<table>` is trusted/internal and can see hidden fields; never return raw trusted rows to users.',
46
+ '- Secure repository choice is not a substitute for authorization. Handlers and hooks still need route access, owner/tenant filters, and explicit checks before returning or mutating records.',
47
+ '- Filters, sort helpers, counts, and aggregates over unpublished fields/private relations are sensitive data surfaces; do not expose them in user-facing endpoints.',
44
48
  '- Use `enfyra_user` as the user table. Model record links as real relations using relation `propertyName` values, not physical FK fields like `userId`, `conversationId`, `senderId`, or `memberId` in generated DB code.',
45
49
  '- Relation design must stay minimal. Create the owning relation needed for writes/filters first; add `inversePropertyName` only when a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use that reverse field. For schema work, explicitly review existing relations and mention which inverses are intentionally present or intentionally omitted.',
46
50
  '- Do not call internal/no-route system tables such as `enfyra_column` or `enfyra_session` through generic CRUD. Use table/column/relation tools and route-backed tables discovered from metadata.',
47
51
  '- Custom API paths use `api_endpoint_workflow` when a handler is needed and the model should follow returned nextSteps. Use lower-level `create_route` without `mainTableId` only when intentionally creating a route shell; `create_table` is only for new persisted data.',
48
52
  '- For canonical table reads and RLS, preserve client-controlled query shape: do not override `@QUERY.fields`, `@QUERY.deep`, `@QUERY.sort`, `@QUERY.limit`, `@QUERY.page`, `@QUERY.meta`, `@QUERY.aggregate`, or `debugMode`. Merge only security filters into `@QUERY.filter`.',
53
+ '- If a REST read returns a column or relation marked `isPublished=false`, including through dotted relation fields such as `fields=owner.secret` or equivalent `deep` projections, treat it as an Enfyra core support issue. Confirm the minimal repro with `test_rest_endpoint`, tell the user to send a Cloud/support ticket with the table, field path, and response shape, and do not present route-local pre-hooks or frontend hiding as the real fix.',
49
54
  '- Script source is `sourceCode`; `compiledCode` is generated and may differ textually because macros expand. Do not warn about source/compiled mismatch unless validation or runtime behavior proves the compiled artifact is stale.',
50
55
  '- For intentional user/domain errors in scripts use `@THROW400`-style helpers or `$ctx.$throw[...]`, not `throw new Error(...)`.',
51
56
  '- Destructive operations are preview-first. Do not pass `confirm=true` until the user explicitly approves.',
@@ -2,6 +2,13 @@ import { z } from 'zod';
2
2
 
3
3
  import { fetchAPI } from './fetch.js';
4
4
  import { validateScriptSourceIfPresent } from './mutation-guards.js';
5
+ import {
6
+ assertDynamicCodeKnowledgeAck,
7
+ assertDynamicCodeKnowledgeAckIf,
8
+ assertExtensionKnowledgeAck,
9
+ dynamicCodeKnowledgeAckParam,
10
+ extensionKnowledgeAckParam,
11
+ } from './required-knowledge.js';
5
12
 
6
13
  function unwrapData(result) {
7
14
  return Array.isArray(result?.data) ? result.data : [];
@@ -656,7 +663,9 @@ async function ensureExtension(apiUrl, {
656
663
  description,
657
664
  isEnabled = true,
658
665
  version = '1.0.0',
666
+ extensionKnowledgeAckKey,
659
667
  }) {
668
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
660
669
  if (type === 'page' && !menuId) {
661
670
  throw new Error('menuId is required for page extensions. Use ensure_menu first, then ensure_page_extension.');
662
671
  }
@@ -718,6 +727,7 @@ async function ensureFlowStep(apiUrl, {
718
727
  scriptLanguage,
719
728
  timeout,
720
729
  isEnabled,
730
+ knowledgeAckKey,
721
731
  }) {
722
732
  if (!flowName && !flowId) throw new Error('Provide flowName or flowId.');
723
733
  if (flowName && flowId) throw new Error('Provide flowName or flowId, not both.');
@@ -726,6 +736,7 @@ async function ensureFlowStep(apiUrl, {
726
736
  : await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
727
737
  if (!flow) throw new Error(`Flow not found: ${flowId || flowName}`);
728
738
  const parsedConfig = parseJsonObjectArg('config', config, {});
739
+ assertDynamicCodeKnowledgeAckIf(Boolean(sourceCode && ['script', 'condition'].includes(type)), knowledgeAckKey);
729
740
  const validation = sourceCode && ['script', 'condition'].includes(type)
730
741
  ? await validateDynamicScript(apiUrl, sourceCode, scriptLanguage)
731
742
  : { validated: false, reason: 'no script validation required' };
@@ -971,7 +982,12 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
971
982
  nextSteps: blocked
972
983
  ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, overwrite: true }, reason: blocked.reason }]
973
984
  : firstRunnable
974
- ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, apply: true }, stepId: firstRunnable.id }]
985
+ ? [{
986
+ tool: 'api_endpoint_workflow',
987
+ input: { path: normalizedPath, method: methodName, apply: true },
988
+ stepId: firstRunnable.id,
989
+ requiresKnowledgeAck: firstRunnable.id === 'save_handler' ? 'dynamicCodeAckKey from get_enfyra_required_knowledge' : undefined,
990
+ }]
975
991
  : [],
976
992
  };
977
993
  }
@@ -1020,6 +1036,7 @@ async function applyApiEndpointWorkflowStep(apiUrl, state, opts, stepId) {
1020
1036
  }
1021
1037
 
1022
1038
  if (selectedStep.id === 'save_handler') {
1039
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1023
1040
  if (!endpoint.routeId) throw new Error('Route must exist before saving handler.');
1024
1041
  const body = {
1025
1042
  sourceCode: opts.sourceCode,
@@ -1097,6 +1114,9 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
1097
1114
  const operations = [];
1098
1115
  let completedEphemeralStepId = null;
1099
1116
  if (opts.apply || opts.applyAll) {
1117
+ if (opts.applyAll && state.steps.some((item) => item.id === 'save_handler' && ['pending', 'waiting'].includes(item.status))) {
1118
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1119
+ }
1100
1120
  const maxSteps = opts.applyAll ? 10 : 1;
1101
1121
  for (let i = 0; i < maxSteps; i += 1) {
1102
1122
  if (state.blocked || !state.firstRunnable) break;
@@ -1369,6 +1389,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1369
1389
  apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1370
1390
  applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1371
1391
  stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1392
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply/applyAll reaches the save_handler step. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1372
1393
  },
1373
1394
  async (input) => jsonText(await runApiEndpointWorkflow(ENFYRA_API_URL, input)),
1374
1395
  );
@@ -1392,8 +1413,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1392
1413
  overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
1393
1414
  smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test after save, e.g. {"a":"1","b":"2"}.'),
1394
1415
  smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test after save.'),
1416
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1395
1417
  },
1396
- async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody }) => {
1418
+ async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody, knowledgeAckKey }) => {
1419
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1397
1420
  const normalizedPath = normalizeRestPath(path);
1398
1421
  const methodName = normalizeMethodName(method);
1399
1422
  const { methodMap, methodIdNameMap } = await getMethodContext(ENFYRA_API_URL);
@@ -1700,8 +1723,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1700
1723
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
1701
1724
  isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
1702
1725
  description: z.string().optional().describe('Admin note.'),
1726
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when sourceCode is provided. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1703
1727
  },
1704
- async ({ path, sourceCode, scriptLanguage, isEnabled, description }) => {
1728
+ async ({ path, sourceCode, scriptLanguage, isEnabled, description, knowledgeAckKey }) => {
1729
+ assertDynamicCodeKnowledgeAckIf(sourceCode !== undefined, knowledgeAckKey);
1705
1730
  const normalizedPath = normalizeRestPath(path);
1706
1731
  const validation = sourceCode === undefined
1707
1732
  ? { validated: false, reason: 'no sourceCode' }
@@ -1730,8 +1755,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1730
1755
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1731
1756
  isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
1732
1757
  description: z.string().optional().describe('Admin note.'),
1758
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1733
1759
  },
1734
- async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description }) => {
1760
+ async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description, knowledgeAckKey }) => {
1761
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1735
1762
  if (!gatewayPath && !gatewayId) throw new Error('Provide gatewayPath or gatewayId.');
1736
1763
  if (gatewayPath && gatewayId) throw new Error('Provide gatewayPath or gatewayId, not both.');
1737
1764
  const gateway = gatewayId
@@ -1833,6 +1860,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1833
1860
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1834
1861
  timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1835
1862
  isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1863
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1836
1864
  },
1837
1865
  async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1838
1866
  ...input,
@@ -1853,6 +1881,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1853
1881
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1854
1882
  timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1855
1883
  isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1884
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1856
1885
  },
1857
1886
  async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1858
1887
  ...input,
@@ -2033,6 +2062,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2033
2062
  description: z.string().optional().describe('Extension description.'),
2034
2063
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2035
2064
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
2065
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2036
2066
  },
2037
2067
  async (input) => jsonText({
2038
2068
  action: 'page_extension_ensured',
@@ -2049,6 +2079,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2049
2079
  description: z.string().optional().describe('Extension description.'),
2050
2080
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2051
2081
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
2082
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2052
2083
  },
2053
2084
  async (input) => jsonText({
2054
2085
  action: 'global_extension_ensured',
@@ -2065,6 +2096,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2065
2096
  description: z.string().optional().describe('Extension description.'),
2066
2097
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2067
2098
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
2099
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2068
2100
  },
2069
2101
  async (input) => jsonText({
2070
2102
  action: 'widget_extension_ensured',
@@ -0,0 +1,116 @@
1
+ export const DYNAMIC_CODE_KNOWLEDGE_ACK_KEY = 'EFYRA::SECURE-REPO-CONTRACT::R9x-kelp-42Q::NO-RAW-TRUSTED';
2
+ export const EXTENSION_KNOWLEDGE_ACK_KEY = 'EFYRA::EXTENSION-THEME-CONTRACT::VIOLET-IS-NOT-A-PLAN::7mQ';
3
+
4
+ const REQUIRED_KNOWLEDGE_VERSION = '2026-06-30.secure-repo-v1';
5
+
6
+ export function dynamicCodeKnowledgeAckParam(z) {
7
+ return z.string().describe('Required dynamic-code acknowledgement key from get_enfyra_required_knowledge. Call that tool, read the dynamic server code knowledge, then pass dynamicCodeAckKey exactly.');
8
+ }
9
+
10
+ export function extensionKnowledgeAckParam(z) {
11
+ return z.string().describe('Required extension acknowledgement key from get_enfyra_required_knowledge. Call that tool, read the extension/theme knowledge, then pass extensionAckKey exactly.');
12
+ }
13
+
14
+ export function assertDynamicCodeKnowledgeAck(key) {
15
+ if (key !== DYNAMIC_CODE_KNOWLEDGE_ACK_KEY) {
16
+ throw new Error('Missing or invalid dynamic-code knowledge acknowledgement. Call get_enfyra_required_knowledge, read the dynamic server code contracts, then pass dynamicCodeAckKey as knowledgeAckKey.');
17
+ }
18
+ }
19
+
20
+ export function assertDynamicCodeKnowledgeAckIf(condition, key) {
21
+ if (condition) assertDynamicCodeKnowledgeAck(key);
22
+ }
23
+
24
+ export function assertExtensionKnowledgeAck(key) {
25
+ if (key !== EXTENSION_KNOWLEDGE_ACK_KEY) {
26
+ throw new Error('Missing or invalid extension knowledge acknowledgement. Call get_enfyra_required_knowledge, read the extension/theme contracts, then pass extensionAckKey as extensionKnowledgeAckKey.');
27
+ }
28
+ }
29
+
30
+ export function assertExtensionKnowledgeAckIf(condition, key) {
31
+ if (condition) assertExtensionKnowledgeAck(key);
32
+ }
33
+
34
+ export function buildRequiredKnowledgePayload() {
35
+ return {
36
+ version: REQUIRED_KNOWLEDGE_VERSION,
37
+ purpose: 'Read this before generating or saving Enfyra dynamic server code through MCP.',
38
+ dynamicCodeAckKey: DYNAMIC_CODE_KNOWLEDGE_ACK_KEY,
39
+ extensionAckKey: EXTENSION_KNOWLEDGE_ACK_KEY,
40
+ usage: [
41
+ 'Pass dynamicCodeAckKey exactly as knowledgeAckKey when calling MCP tools that create or update dynamic server code.',
42
+ 'Pass extensionAckKey exactly as extensionKnowledgeAckKey when calling MCP tools that create or update Enfyra extension code.',
43
+ ],
44
+ dynamicServerCode: [
45
+ {
46
+ id: 'secure-vs-trusted-repositories',
47
+ rules: [
48
+ '@REPOS.main is the secure repository for the current route main table and preserves normal route query behavior.',
49
+ '@REPOS.secure.<table> is the secure explicit-table repository. Use it for public/user-facing custom handlers, hooks, websocket scripts, flows that return data, and third-party app integrations.',
50
+ '@REPOS.<table> is the trusted internal repository. It may read/write hidden fields and is only for server-owned maintenance/admin logic that intentionally needs that access.',
51
+ 'Never return raw trusted-repository records to users. Project or sanitize output before returning it.',
52
+ ],
53
+ },
54
+ {
55
+ id: 'authorization-is-separate',
56
+ rules: [
57
+ 'Secure repository selection does not prove the user is allowed to access a record.',
58
+ 'Handlers and hooks still need route access, owner/tenant filters, membership checks, and explicit mutation authorization.',
59
+ 'For canonical table reads and RLS, merge security filters into @QUERY.filter and preserve @QUERY.fields, @QUERY.deep, @QUERY.sort, @QUERY.limit, @QUERY.page, @QUERY.meta, @QUERY.aggregate, and debugMode.',
60
+ ],
61
+ },
62
+ {
63
+ id: 'hidden-field-query-surfaces',
64
+ rules: [
65
+ 'Unpublished fields and private relations are sensitive even when the field is not selected.',
66
+ 'Do not expose filter predicate-oracle behavior over hidden fields in user-facing endpoints.',
67
+ 'Do not expose aggregate, _max, _min, _count, sort helpers, or counts over unpublished fields/private relations unless the endpoint intentionally exposes that fact.',
68
+ 'If a normal REST read returns an isPublished=false field through fields/deep/dotted projection, treat it as an Enfyra core bug and verify the minimal REST repro.',
69
+ ],
70
+ },
71
+ {
72
+ id: 'dynamic-script-shape',
73
+ rules: [
74
+ 'Use sourceCode and scriptLanguage; never send compiledCode.',
75
+ 'Prefer macros such as @BODY, @QUERY, @PARAMS, @USER, @REQ, @RES, @REPOS, @HELPERS, @STORAGE, @SOCKET, and @THROW* when available.',
76
+ 'Repository reads use filter, not where.',
77
+ 'Create/update repository calls return collection-shaped data arrays; read result.data?.[0] for a single row.',
78
+ ],
79
+ },
80
+ ],
81
+ extensions: [
82
+ {
83
+ id: 'theme-contract-first',
84
+ rules: [
85
+ 'Call get_extension_theme_contract before writing or reviewing page, widget, or global extension UI.',
86
+ 'Call get_theme_class_reference when an exact eapp-* class or Nuxt UI color mapping is needed.',
87
+ 'Use eapp-surface-*, eapp-text-*, eapp-divide-y, and eapp-divider for neutral app-shell surfaces.',
88
+ 'Use eapp-primary-* or Nuxt UI primary only for runtime-primary identity/accent intent controlled by the app color picker.',
89
+ 'Use semantic state colors only for true status/error/warning/success/info indicators, not for large KPI/list containers.',
90
+ 'Do not use raw CSS variable utilities such as text-[var(...)], bg-[var(...)], or border-[var(...)] when class tokens exist.',
91
+ 'Do not hard-code concrete palettes such as color="violet" or Tailwind palette accents for themeable UI.',
92
+ ],
93
+ },
94
+ {
95
+ id: 'extension-shell-boundary',
96
+ rules: [
97
+ 'Extension roots render inside the Enfyra admin app shell. Do not add root-level page padding such as p-4 sm:p-6 xl:p-8.',
98
+ 'Page extensions should be full-bleed by default and responsive from the first version.',
99
+ 'Do not wrap whole pages in decorative cards; use cards only for repeated items, modals, or genuinely framed tools.',
100
+ 'Use NuxtLink or Nuxt UI components with :to for visible navigation links; reserve navigateTo for imperative side effects.',
101
+ 'Admin extension links for record management should point to /data/<table>, not public website paths stored on records.',
102
+ ],
103
+ },
104
+ {
105
+ id: 'extension-runtime-contract',
106
+ rules: [
107
+ 'Save extensions as enfyra_extension Vue SFC records; no static import statements in extension code.',
108
+ 'Load app packages with getPackages(["package-name"]) inside extension runtime code.',
109
+ 'Prefer FormEditor/FormEditorLazy for direct table-backed forms when the form maps to metadata fields.',
110
+ 'For long admin setup workflows, open CommonDrawer immediately and show loading/error/content inside it.',
111
+ 'Use Widget with numeric enfyra_extension widget ids; pass safe reactive props/events and keep page-level mutation ownership in the page unless the widget intentionally owns the workflow.',
112
+ ],
113
+ },
114
+ ],
115
+ };
116
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ const DEFAULT_PREVIEW_CHARS = 1200;
7
+ const DEFAULT_INLINE_LIMIT = 1400;
8
+ const SOURCE_FIELD_NAMES = new Set([
9
+ 'sourceCode',
10
+ 'code',
11
+ 'compiledCode',
12
+ 'handlerScript',
13
+ 'connectionHandlerScript',
14
+ ]);
15
+
16
+ function sha256(value) {
17
+ return createHash('sha256').update(String(value), 'utf8').digest('hex');
18
+ }
19
+
20
+ function safePart(value) {
21
+ const source = String(value || 'source').trim();
22
+ return source.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'source';
23
+ }
24
+
25
+ function extensionForField(fieldName) {
26
+ if (fieldName === 'code') return '.vue';
27
+ return '.js';
28
+ }
29
+
30
+ export function writeSourceArtifact({ tableName, id, fieldName, source }) {
31
+ const hash = sha256(source);
32
+ const dir = join(tmpdir(), 'enfyra-mcp-sources');
33
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
34
+ const fileName = [
35
+ safePart(tableName),
36
+ safePart(id),
37
+ safePart(fieldName),
38
+ hash.slice(0, 12),
39
+ ].join('-') + extensionForField(fieldName);
40
+ const path = join(dir, fileName);
41
+ writeFileSync(path, source, { mode: 0o600 });
42
+ return {
43
+ tmpFile: path,
44
+ length: source.length,
45
+ sha256: hash,
46
+ preview: source.length > DEFAULT_PREVIEW_CHARS
47
+ ? `${source.slice(0, DEFAULT_PREVIEW_CHARS)}...`
48
+ : source,
49
+ };
50
+ }
51
+
52
+ export function compactSourceField({ tableName, id, fieldName, source, alwaysWrite = false }) {
53
+ if (typeof source !== 'string') return source;
54
+ if (!alwaysWrite && source.length <= DEFAULT_INLINE_LIMIT) return source;
55
+ return writeSourceArtifact({ tableName, id, fieldName, source });
56
+ }
57
+
58
+ export function compactSourceFields(value, { tableName, idField = 'id', alwaysWrite = false } = {}) {
59
+ if (Array.isArray(value)) {
60
+ return value.map((item) => compactSourceFields(item, { tableName, idField, alwaysWrite }));
61
+ }
62
+ if (!value || typeof value !== 'object') return value;
63
+
64
+ const recordId = value[idField] ?? value._id ?? value.id ?? 'record';
65
+ const out = {};
66
+ for (const [key, fieldValue] of Object.entries(value)) {
67
+ if (SOURCE_FIELD_NAMES.has(key) && typeof fieldValue === 'string') {
68
+ out[key] = compactSourceField({
69
+ tableName,
70
+ id: recordId,
71
+ fieldName: key,
72
+ source: fieldValue,
73
+ alwaysWrite,
74
+ });
75
+ } else if (fieldValue && typeof fieldValue === 'object') {
76
+ out[key] = compactSourceFields(fieldValue, { tableName, idField, alwaysWrite });
77
+ } else {
78
+ out[key] = fieldValue;
79
+ }
80
+ }
81
+ return out;
82
+ }
@@ -22,9 +22,18 @@ import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instruct
22
22
  import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
23
23
  import { registerTableTools } from './lib/table-tools.js';
24
24
  import { registerPlatformOperationTools } from './lib/platform-operation-tools.js';
25
- import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
25
+ import { parseRecordData, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
26
+ import {
27
+ assertDynamicCodeKnowledgeAck,
28
+ assertDynamicCodeKnowledgeAckIf,
29
+ assertExtensionKnowledgeAckIf,
30
+ buildRequiredKnowledgePayload,
31
+ dynamicCodeKnowledgeAckParam,
32
+ extensionKnowledgeAckParam,
33
+ } from './lib/required-knowledge.js';
26
34
  import { validateMainTableRoutePath } from './lib/route-guards.js';
27
35
  import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
36
+ import { compactSourceFields, writeSourceArtifact } from './lib/source-artifacts.js';
28
37
  import {
29
38
  findRoutePermission,
30
39
  mergeMethodNames,
@@ -208,6 +217,7 @@ const SCRIPT_BACKED_TABLES = [
208
217
  'enfyra_graphql',
209
218
  'enfyra_bootstrap_script',
210
219
  ];
220
+ const SCRIPT_BACKED_TABLE_SET = new Set(SCRIPT_BACKED_TABLES);
211
221
 
212
222
  const SCRIPT_SOURCE_FIELDS = [
213
223
  'sourceCode',
@@ -596,6 +606,12 @@ async function prepareGenericMutation(tableName, data) {
596
606
  });
597
607
  }
598
608
 
609
+ function assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey }) {
610
+ const payload = parseRecordData(data);
611
+ assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
612
+ assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
613
+ }
614
+
599
615
  function parseQueryParamsArg(queryParams) {
600
616
  const parsed = parseJsonArg(queryParams, {});
601
617
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -775,6 +791,16 @@ installColumnarToolFormatter(server);
775
791
  // METADATA TOOLS
776
792
  // ============================================================================
777
793
 
794
+ server.tool(
795
+ 'get_enfyra_required_knowledge',
796
+ [
797
+ 'Return required Enfyra knowledge and acknowledgement keys for MCP code-writing tools.',
798
+ '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.',
799
+ ].join(' '),
800
+ {},
801
+ async () => jsonContent(buildRequiredKnowledgePayload()),
802
+ );
803
+
778
804
  server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Use get_table_metadata or inspect_table for detail.', {
779
805
  includeFull: z.boolean().optional().default(false).describe('Return full raw metadata. Default false to keep MCP context small.'),
780
806
  search: z.string().optional().describe('Optional table-name/alias substring filter.'),
@@ -1051,6 +1077,7 @@ server.tool(
1051
1077
  deep: 'Nested relation fetch object keyed by relation propertyName.',
1052
1078
  },
1053
1079
  countPattern: 'For counts, query only fields=id with limit=1 and request meta. Use meta=totalCount without a filter, or meta=filterCount when a filter is supplied. MCP count_records wraps this pattern.',
1080
+ 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.',
1054
1081
  deep: {
1055
1082
  shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
1056
1083
  rules: [
@@ -1180,7 +1207,7 @@ server.tool(
1180
1207
  },
1181
1208
  handler: {
1182
1209
  runs: 'Main route logic, or canonical CRUD if no handler overrides.',
1183
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@RES when response streaming is available', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main', '@REPOS.<table>', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@PKGS', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1210
+ 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', '@REPOS.secure.<table> secure explicit table repo', '@REPOS.<table> trusted internal table repo', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@PKGS', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1184
1211
  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.',
1185
1212
  returnBehavior: 'Return value becomes response body unless post-hook changes it.',
1186
1213
  },
@@ -1217,7 +1244,9 @@ server.tool(
1217
1244
  },
1218
1245
  helpers: {
1219
1246
  repos: {
1220
- scopes: '$repos.main is the canonical repository for the route main table and preserves normal route query behavior. $repos.<table> is an explicit internal repository for table-specific logic. Do not generate $repos.secure.<table>; current runtime does not expose table methods there.',
1247
+ scopes: '$repos.main is the secure repository for the route main table and preserves normal route query behavior. $repos.secure.<table> is the secure repository for explicit table access in public/user-facing custom handlers, hooks, websocket scripts, flows that return data, and third-party app integrations. $repos.<table> is a trusted internal repository for server-owned maintenance/admin logic that intentionally needs hidden fields; never return raw trusted-repo records to users.',
1248
+ security: 'Secure repos enforce the normal field visibility/projection path, including unpublished columns and relations. Trusted repos bypass that exposure boundary; if trusted access is necessary, project or sanitize the result before returning it. Authorization is still required in either path: enforce route access plus owner/tenant filters or membership checks.',
1249
+ 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.',
1221
1250
  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.',
1222
1251
  preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
1223
1252
  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.',
@@ -1288,7 +1317,7 @@ server.tool('query_table', 'Query any route-backed table. Response is minimal un
1288
1317
  fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
1289
1318
  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.'),
1290
1319
  deep: z.string().optional().describe('Optional deep relation fetch object as JSON string. Keys must be relation propertyName values.'),
1291
- aggregate: z.string().optional().describe('Optional aggregate object as JSON string, keyed by real fields/relations. Results are returned in response.meta.aggregate when supported.'),
1320
+ aggregate: z.string().optional().describe('Optional aggregate object as JSON string, keyed by real fields/relations. Results are returned in response.meta.aggregate when supported. Do not request aggregates over hidden fields/private relations in user-facing APIs.'),
1292
1321
  }, async ({ tableName, filter, sort, page, limit, all, fields, meta, deep, aggregate }) => {
1293
1322
  if (!all && limit === undefined) {
1294
1323
  throw new Error('query_table requires either limit or all=true. Do not rely on implicit default page sizes.');
@@ -1329,7 +1358,7 @@ server.tool('query_table', 'Query any route-backed table. Response is minimal un
1329
1358
  },
1330
1359
  minimalDefaultApplied: !(fields && fields.length > 0),
1331
1360
  meta: result?.meta,
1332
- data: result?.data || [],
1361
+ data: compactSourceFields(result?.data || [], { tableName }),
1333
1362
  detailHint: fields && fields.length > 0
1334
1363
  ? undefined
1335
1364
  : '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.',
@@ -1410,7 +1439,7 @@ server.tool(
1410
1439
  tableName,
1411
1440
  primaryKey,
1412
1441
  fields: selectedFields,
1413
- data: one,
1442
+ data: compactSourceFields(one, { tableName }),
1414
1443
  detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
1415
1444
  }, null, 2) }] };
1416
1445
  }
@@ -1428,7 +1457,7 @@ server.tool(
1428
1457
  return { content: [{ type: 'text', text: JSON.stringify({
1429
1458
  tableName,
1430
1459
  fields: selectedFields,
1431
- data: result.data?.[0] || null,
1460
+ data: compactSourceFields(result.data?.[0] || null, { tableName }),
1432
1461
  detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
1433
1462
  }, null, 2) }] };
1434
1463
  },
@@ -1442,8 +1471,11 @@ server.tool('create_record', 'Create a new record in any route-backed table. The
1442
1471
  tableName: z.string().describe('Table name to insert into'),
1443
1472
  data: z.string().describe('Record data as JSON string'),
1444
1473
  queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
1445
- }, async ({ tableName, data, queryParams }) => {
1474
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1475
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1476
+ }, async ({ tableName, data, queryParams, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1446
1477
  validateTableName(tableName);
1478
+ assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1447
1479
  const prepared = await prepareGenericMutation(tableName, data);
1448
1480
  const query = parseQueryParamsArg(queryParams);
1449
1481
  const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
@@ -1458,8 +1490,11 @@ server.tool('update_record', 'Update an existing record by ID using PATCH. The t
1458
1490
  id: z.string().describe('Record ID to update'),
1459
1491
  data: z.string().describe('Fields to update as JSON string'),
1460
1492
  queryParams: z.string().optional().describe('Optional query params as JSON object string for route contracts that intentionally keep workflow fields out of the validated body.'),
1461
- }, async ({ tableName, id, data, queryParams }) => {
1493
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1494
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1495
+ }, async ({ tableName, id, data, queryParams, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1462
1496
  validateTableName(tableName);
1497
+ assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1463
1498
  const prepared = await prepareGenericMutation(tableName, data);
1464
1499
  const query = parseQueryParamsArg(queryParams);
1465
1500
  const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
@@ -1481,12 +1516,14 @@ server.tool(
1481
1516
  },
1482
1517
  async ({ tableName, id }) => {
1483
1518
  const { primaryKey, record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1519
+ const sourceArtifact = writeSourceArtifact({ tableName, id, fieldName: sourceField, source: sourceCode });
1484
1520
  return { content: [{ type: 'text', text: JSON.stringify({
1485
1521
  tableName,
1486
1522
  id,
1487
1523
  primaryKey,
1488
1524
  sourceField,
1489
- sourceCode,
1525
+ sourceFile: sourceArtifact.tmpFile,
1526
+ sourcePreview: sourceArtifact.preview,
1490
1527
  sourceLength: sourceCode.length,
1491
1528
  sourceSha256: sha256(sourceCode),
1492
1529
  scriptLanguage: record.scriptLanguage || record.language || null,
@@ -1511,8 +1548,9 @@ server.tool(
1511
1548
  expectedSourceSha256: z.string().optional().describe('Optional SHA-256 from get_script_source; fails if source changed.'),
1512
1549
  scriptLanguage: z.string().optional().describe('Script language to save. Defaults to existing scriptLanguage or javascript.'),
1513
1550
  apply: z.boolean().optional().default(false).describe('false returns preview only; true validates and saves.'),
1551
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply=true. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1514
1552
  },
1515
- async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply }) => {
1553
+ async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply, knowledgeAckKey }) => {
1516
1554
  const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1517
1555
  if (sourceField !== 'sourceCode') {
1518
1556
  throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_record intentionally for this legacy field.`);
@@ -1543,6 +1581,7 @@ server.tool(
1543
1581
  if (!apply) {
1544
1582
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1545
1583
  }
1584
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1546
1585
  const language = scriptLanguage || record.scriptLanguage || 'javascript';
1547
1586
  const prepared = await prepareGenericMutation(
1548
1587
  tableName,
@@ -1584,8 +1623,10 @@ server.tool(
1584
1623
  id: z.string().describe('Record ID to update'),
1585
1624
  sourceCode: z.string().describe('Editable script sourceCode. Pass the raw code string; do not JSON-escape it yourself.'),
1586
1625
  scriptLanguage: z.string().optional().default('javascript').describe('Script language, usually javascript or typescript'),
1626
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1587
1627
  },
1588
- async ({ tableName, id, sourceCode, scriptLanguage }) => {
1628
+ async ({ tableName, id, sourceCode, scriptLanguage, knowledgeAckKey }) => {
1629
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1589
1630
  validateTableName(tableName);
1590
1631
  const prepared = await prepareGenericMutation(
1591
1632
  tableName,
@@ -2472,8 +2513,10 @@ server.tool(
2472
2513
  sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic.'),
2473
2514
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2474
2515
  timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
2516
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2475
2517
  },
2476
- async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout }) => {
2518
+ async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout, knowledgeAckKey }) => {
2519
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2477
2520
  const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
2478
2521
  if (methodNames.length === 0) throw new Error('Provide method or methods');
2479
2522
  const methodMap = await getMethodMap();
@@ -2533,8 +2576,10 @@ server.tool(
2533
2576
  .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2534
2577
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2535
2578
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2579
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2536
2580
  },
2537
- async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
2581
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, knowledgeAckKey }) => {
2582
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2538
2583
  const methodMap = await getMethodMap();
2539
2584
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2540
2585
  const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_pre_hook', {
@@ -2587,8 +2632,10 @@ server.tool(
2587
2632
  .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2588
2633
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2589
2634
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2635
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2590
2636
  },
2591
- async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
2637
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, knowledgeAckKey }) => {
2638
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2592
2639
  const methodMap = await getMethodMap();
2593
2640
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2594
2641
  const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_post_hook', {