@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 +6 -1
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +22 -9
- package/src/lib/mcp-instructions.js +9 -4
- package/src/lib/platform-operation-tools.js +36 -4
- package/src/lib/required-knowledge.js +116 -0
- package/src/lib/source-artifacts.js +82 -0
- package/src/mcp-server-entry.mjs +62 -15
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 `
|
|
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
package/src/lib/mcp-examples.js
CHANGED
|
@@ -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.
|
|
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
|
|
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"`.
|
|
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
|
-
? [{
|
|
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
|
+
}
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', {
|