@enfyra/mcp-server 0.0.77 → 0.0.79

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
@@ -192,7 +192,7 @@ Generated RLS for canonical table reads must keep projection and pagination clie
192
192
 
193
193
  Quick checklist for a new LLM using Enfyra MCP: discover the live system first, inspect the specific table/route, load the matching example category, mutate with explicit fields and relation property names, validate or test scripts/routes before relying on them, re-read the saved row when mutation output is summarized, and preview destructive operations before confirming.
194
194
 
195
- Use `update_script_source` when updating existing long script-backed records such as `flow_step_definition`, `route_handler_definition`, hook tables, websocket scripts, GraphQL scripts, or bootstrap scripts. It accepts raw `sourceCode` directly, validates the source, and saves `sourceCode`/`scriptLanguage` without requiring the caller to manually JSON-escape the full script. Use generic `update_record` for small record patches or patches that include non-script metadata fields.
195
+ Use `trace_metadata_usage` before changing production features to find related tables, routes, handlers, hooks, flow steps, websocket scripts, GraphQL scripts, and bootstrap scripts. Use `get_script_source` to read full untruncated source plus a SHA-256 hash. Prefer `patch_script_source` for focused exact search/replace edits; it previews by default and, with `apply=true`, validates the patched `sourceCode` through `/admin/script/validate` before saving. Use `update_script_source` when replacing an entire existing script. Use generic `update_record` only for small record patches or patches that include non-script metadata fields.
196
196
 
197
197
  For route contracts that intentionally keep workflow fields out of request bodies, generic `create_record`, `update_record`, and `delete_record` accept optional `queryParams` as a JSON object string. For example, a renewal workflow can keep `expires_at=YYYY-MM-DD` in the URL query while `validateBody` remains enabled for the table body.
198
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.77",
3
+ "version": "0.0.79",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -697,7 +697,7 @@ ensure_route_access({
697
697
  notes: [
698
698
  'Use route permissions for authenticated access. The tool resolves role and method ids, validates the route available methods, merges existing methods, and reloads routes.',
699
699
  'Handlers or pre-hooks must still enforce owner or tenant scope; route permission only lets the request pass RoleGuard.',
700
- 'Use publishedMethods only for anonymous public access.',
700
+ 'Use publicMethods only for anonymous public access.',
701
701
  ],
702
702
  },
703
703
  {
@@ -706,12 +706,12 @@ ensure_route_access({
706
706
  tableName: "route_definition",
707
707
  id: "<route_id>",
708
708
  data: {
709
- publishedMethods: [{ id: "<GET_method_id_from_list_methods>" }]
709
+ publicMethods: [{ id: "<GET_method_id_from_list_methods>" }]
710
710
  }
711
711
  })`,
712
712
  notes: [
713
713
  'Method ids are instance data. Use list_methods or inspect_route output to resolve the GET method id first.',
714
- 'publishedMethods controls anonymous route access. Route permissions are not for public access.',
714
+ 'publicMethods controls anonymous route access. Route permissions are not for public access.',
715
715
  'Route permissions apply when the method is not public.',
716
716
  ],
717
717
  },
@@ -901,7 +901,7 @@ if (message?.id) {
901
901
  data: { lastMessage: { id: message.id }, updatedAt: message.createdAt || new Date().toISOString() }
902
902
  })
903
903
  }
904
- @SOCKET.emitToRoom(\`conversation:\${conversationId}\`, "chat:message", {
904
+ @SOCKET.emitToCurrentRoom(\`conversation:\${conversationId}\`, "chat:message", {
905
905
  clientId,
906
906
  message
907
907
  })
@@ -34,6 +34,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
34
34
  '- If the question depends on DB type, primary key convention, cache/reload/runtime state, active GraphQL/flow/websocket/storage counts, or admin surfaces, call **`discover_runtime_context`**.',
35
35
  '- If the question depends on filters, sorting, deep relations, relation property names, field permissions, or table-specific query examples, call **`discover_query_capabilities`**; pass `tableName` when known.',
36
36
  '- If writing or reviewing handler/hook/flow/websocket/extension logic, call **`discover_script_contexts`** first so macros and `$ctx` fields are not mixed across runtime surfaces.',
37
+ '- If changing or auditing an existing feature, table, route, or long script, call **`trace_metadata_usage`** to find related tables/routes/script-backed records, then **`get_script_source`** for full untruncated source. Use **`patch_script_source`** for exact previewed search/replace edits with hash checking and validation.',
37
38
  '- If generating concrete code, schema payloads, SSR app config, OAuth wiring, Socket.IO clients/events, flows, files, extensions, or permission/RLS examples, call **`get_enfyra_examples`** for the matching category before writing the final answer. Examples are grouped by category and are intentionally more concrete than these global rules.',
38
39
  '- Treat hardcoded instructions as operating rules, but use live discovery as the final check for this running instance. Do not infer missing capabilities from a narrow tool schema; check metadata/routes or the relevant specialized tool first.',
39
40
  '- If there is no dedicated MCP tool for a subsystem, use the route-backed metadata table with `query_table` / `create_record` / `update_record` / `delete_record`, after confirming that table has a route. If the table is no-route, use the canonical specialized tool or parent table workflow instead.',
@@ -113,17 +114,17 @@ export function buildMcpServerInstructions(apiBaseUrl) {
113
114
  '- For generic MCP `create_record` and `update_record`, the `data` argument is a **JSON string**, not a JavaScript object. Example: `data: "{\\"name\\":\\"Starter\\"}"`. If the host gives a validation error saying `data` expected string, stringify the object before calling the tool.',
114
115
  '- Generic MCP `create_record` and `update_record` validate body keys against live metadata before sending REST. If a field such as `expiredAt` is not in metadata, do not bypass body validation; add the metadata field through schema tools or pass route workflow fields in the tool `queryParams` JSON when that route explicitly owns a query contract such as `{"expired_at":"2026-09-20"}`.',
115
116
  '- Generic MCP script-table mutations reject `compiledCode`, reject legacy `code` aliases, and must validate `sourceCode` with `/admin/script/validate` before saving. If validation is unavailable or fails, the save must fail closed. Prefer dedicated `create_handler`, `create_pre_hook`, and `create_post_hook` tools for route code.',
116
- '- For long script updates on existing flow steps, handlers, hooks, websocket scripts, GraphQL scripts, or bootstrap scripts, prefer **`update_script_source`** over generic `update_record`. It accepts raw `sourceCode` as a normal tool argument, validates it, then PATCHes `sourceCode`/`scriptLanguage`; this avoids brittle manual JSON escaping for large scripts.',
117
+ '- For long script updates on existing flow steps, handlers, hooks, websocket scripts, GraphQL scripts, or bootstrap scripts, call **`get_script_source`** first to read full source and hash. Prefer **`patch_script_source`** for focused exact edits with preview/hash validation, or **`update_script_source`** when replacing the entire source. Avoid generic `update_record` for long script changes.',
117
118
  '- Generic MCP `delete_record` is destructive but preview-first: the first call without `confirm=true` returns the target preview; call it again with `confirm=true` only after the user explicitly approves deletion. Use `queryParams` for route-specific confirmation contracts instead of putting those fields in the body.',
118
- '- Relation fields (publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
119
+ '- Relation fields (publicMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
119
120
  '- **mainTable warning:** do not set `mainTable` on custom routes. It is reserved for canonical table routes only.',
120
121
  ' - **Many-to-one:** `"someRelation": {"id": 4}` (single object with id)',
121
- ' - **One-to-many / many-to-many:** `"publishedMethods": [{"id": 1}, {"id": 2}]` (array of objects with id)',
122
- '- **Method IDs** are instance data, not a stable contract. Query `method_definition` or use method names through MCP route helpers before setting `publishedMethods`, `availableMethods`, `skipRoleGuardMethods`, hook methods, handler methods, or route permissions. Default CRUD records are `GET`, `POST`, `PATCH`, and `DELETE`; create extra records such as `PUT` through method tools when a route needs them.',
122
+ ' - **One-to-many / many-to-many:** `"publicMethods": [{"id": 1}, {"id": 2}]` (array of objects with id)',
123
+ '- **Method IDs** are instance data, not a stable contract. Query `method_definition` or use method names through MCP route helpers before setting `publicMethods`, `availableMethods`, `skipRoleGuardMethods`, hook methods, handler methods, or route permissions. Default CRUD records are `GET`, `POST`, `PATCH`, and `DELETE`; create extra records such as `PUT` through method tools when a route needs them.',
123
124
  '- `method_definition.name` is the unique backend field for the HTTP method label. Do not filter, create, or update a `method_definition.method` field. MCP method tools accept an input named `method` for usability, but they write/read `name` on the server.',
124
- '- **Wrong:** `"publishedMethods": ["GET"]` or `"publishedMethods": [{"method": "GET"}]` — rejected or silently ignored.',
125
- '- **Right:** first query method records, then pass their ids, for example `"publishedMethods": [{"id": <GET_METHOD_ID>}]`. Multiple methods use multiple id objects.',
126
- '- **To unset:** pass empty array `"publishedMethods": []`.',
125
+ '- **Wrong:** `"publicMethods": ["GET"]` or `"publicMethods": [{"method": "GET"}]` — rejected or silently ignored.',
126
+ '- **Right:** first query method records, then pass their ids, for example `"publicMethods": [{"id": <GET_METHOD_ID>}]`. Multiple methods use multiple id objects.',
127
+ '- **To unset:** pass empty array `"publicMethods": []`.',
127
128
  '',
128
129
  '### Dynamic script `$repos` mutation return shape',
129
130
  '- In handler/hook/flow/websocket scripts, `$ctx.$repos.<table>.create({ data })` and `$ctx.$repos.<table>.update({ id, data })` return the same collection-shaped result as `find`: `{ data: [...], count? }`.',
@@ -131,13 +132,13 @@ export function buildMcpServerInstructions(apiBaseUrl) {
131
132
  '- Wrong: `const id = result.data.id`, `return result.data`, or assuming `create`/`update` returns the bare row object when the script needs one record.',
132
133
  '- Right: `const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;`.',
133
134
  '',
134
- '### Auth and publishedMethods (Enfyra server)',
135
- '- Each route has **publishedMethods** (which HTTP verbs are “public”) and **routePermissions** (roles/users for protected access).',
136
- '- If the **current request method** is listed in **publishedMethods** for that route, the server allows the call **without** a Bearer token (`RoleGuard`).',
135
+ '### Auth and publicMethods (Enfyra server)',
136
+ '- Each route has **publicMethods** (which HTTP verbs are “public”) and **routePermissions** (roles/users for protected access).',
137
+ '- If the **current request method** is listed in **publicMethods** for that route, the server allows the call **without** a Bearer token (`RoleGuard`).',
137
138
  '- Otherwise the client must send an **Authorization** header with **Bearer** JWT from login. Then the user must satisfy **routePermissions** (unless root admin).',
138
139
  '- Owner-scoped GET handlers must preserve caller filters and merge the owner scope for normal users, while root admin operational views may bypass only the owner scope with `@USER.isRootAdmin` and must keep caller filters intact. Never replace `@QUERY.filter` with `{}` for root admins; doing so breaks detail reads, pagination, and `debugMode=true` explain checks.',
139
140
  '- GET pre-hooks and handlers must preserve client-controlled query shape: do not override `@QUERY.fields`, `@QUERY.deep`, `@QUERY.sort`, `@QUERY.limit`, `@QUERY.page`, `@QUERY.meta`, `@QUERY.aggregate`, or `debugMode` for canonical table reads. RLS may only merge security filters into `@QUERY.filter`. If a route intentionally returns a fixed summary/aggregate workflow response, make it a clearly custom endpoint contract instead of changing canonical table projection.',
140
- '- MCP tools that use `fetchAPI` authenticate with the configured `ENFYRA_API_TOKEN`. Explain to users that **direct HTTP** calls need a Bearer token unless the route/method is published.',
141
+ '- MCP tools that use `fetchAPI` authenticate with the configured `ENFYRA_API_TOKEN`. Explain to users that **direct HTTP** calls need a Bearer token unless the route/method is public.',
141
142
  '',
142
143
  '### Post-hooks (REST)',
143
144
  '- **post-hooks always run** after the handler, including when the handler or a pre-hook throws — then `@ERROR` / `$ctx.$error` is set and `@DATA` is null.',
@@ -150,7 +151,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
150
151
  '- Enfyra scripts use `$helpers.$crypto` for bounded crypto helpers such as `randomUUID()`, `randomBytes(size, encoding)`, `sha256(value, encoding)`, `hmacSha256(value, secret, encoding)`, and `generateSshKeyPair(comment)`. Do not generate legacy `$helpers.$ssh` or `$helpers.$secrets` usage.',
151
152
  '- `$ctx.$env` exposes only a sanitized process env snapshot. Current OSS deny keys are exact matches: `DB_URI`, `DB_REPLICA_URIS`, `REDIS_URI`, `SECRET_KEY`, and `ADMIN_PASSWORD`. Do not read secrets from `$ctx.$env`; model app secrets as unpublished `isEncrypted=true` fields instead.',
152
153
  '- Script-backed records use one shared persistence contract: `sourceCode` is the editable source, `scriptLanguage` controls compilation, and `compiledCode` is generated by the server from `sourceCode`. Do not hand-edit or send stale `compiledCode` from generated tools; save `sourceCode`/`scriptLanguage` through `PATCH /<script_table>/<id>` and let the server persist generated `compiledCode` internally. Public metadata may mark `compiledCode` non-updatable, but the server engine must still preserve the generated value after normalization.',
153
- '- Use MCP `update_script_source` for existing long script-backed records so callers pass raw source text instead of hand-escaped JSON strings. Use generic `update_record` only when the patch is small or includes non-script metadata fields.',
154
+ '- Use MCP `get_script_source` for full untruncated source, `patch_script_source` for focused exact edits with preview/hash validation, and `update_script_source` for full-source replacement. Use generic `update_record` only when the patch is small or includes non-script metadata fields.',
154
155
  '- For route handlers specifically, the field is also `sourceCode`. Older names such as `logic` are wrong for current Enfyra REST CRUD and will be rejected. Use MCP `create_handler` so it writes `sourceCode` and resolves method ids correctly.',
155
156
  '- MCP `create_pre_hook` and `create_post_hook` accept a user-facing `code` argument but persist it as `sourceCode` with `scriptLanguage`. Do not call raw `create_record` with a `code` field for hook tables; backend request validation rejects `code` on REST CRUD.',
156
157
  '- Before saving generated script code, validate it with `POST /admin/script/validate` when available. It compiles with the server kernel and parses the executable async body without running side effects. Enfyra App `FormCodeEditor` also exposes a `Validate` action for this endpoint; use it before save/run when editing through the UI. If unavailable, use `run_admin_test`/`test_flow_step` as the closest validation path before saving.',
@@ -258,7 +259,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
258
259
  `- **GET** \`${graphqlSchemaUrl}\` — current schema SDL (text); same base pattern as above.`,
259
260
  '- A table appears in the schema when `gql_definition` has an enabled row for that table. The REST route `availableMethods` list does not enable GraphQL.',
260
261
  '- **Query** field = same string as `table_definition.name`. **Mutations** are literal concat: `create_`+tableName, `update_`+tableName, `delete_`+tableName (e.g. tableName `post` → `create_post`, input type `postInput`). See `generate-type-defs.ts`. If every column is skipped for input (only PK, or only `createdAt`/`updatedAt`, or all unpublished), the schema emits **no** `Query.<tableName>` and **no** create/update/delete mutations for that table (an output `type` may still exist for relation wiring).',
261
- '- **Auth:** GraphQL currently requires `Authorization: Bearer <accessToken>`. REST route `publishedMethods` does not make GraphQL anonymous.',
262
+ '- **Auth:** GraphQL currently requires `Authorization: Bearer <accessToken>`. REST route `publicMethods` does not make GraphQL anonymous.',
262
263
  '- **Management workflow:** use `update_table` with `graphqlEnabled: true|false`, or create/update `gql_definition` with `table: {id}` and `isEnabled`. Reload GraphQL with `reload_graphql` if the cache has not refreshed yet.',
263
264
  '- MCP does not wrap GraphQL; use REST tools or tell users the URLs above.',
264
265
  '',
@@ -271,11 +272,13 @@ export function buildMcpServerInstructions(apiBaseUrl) {
271
272
  '- `@SOCKET.join(room)` — join a room (WS context only).',
272
273
  '- `@SOCKET.leave(room)` — leave a room (WS context only).',
273
274
  '- `@SOCKET.emitToUser(userId, event, data)` — send to a specific user (across all gateways).',
274
- '- `@SOCKET.emitToRoom(room, event, data)` — send to a named room (across all gateways).',
275
+ '- `@SOCKET.emitToRoom(path, room, event, data)` — send to a named room in a specific gateway/namespace.',
276
+ '- `@SOCKET.emitToCurrentRoom(room, event, data)` — send to a named room in the current websocket gateway/namespace (WS context only).',
277
+ '- `@SOCKET.broadcastToRoom(room, event, data)` — send to a named room in the current websocket gateway/namespace except the triggering socket (WS context only).',
275
278
  '- `@SOCKET.emitToGateway(path, event, data)` — broadcast to all connections on a gateway/namespace.',
276
279
  '- `@SOCKET.broadcast(event, data)` — broadcast to all connections on all gateways.',
277
280
  '- `@SOCKET.disconnect()` — force-disconnect the current socket from the gateway (WS context only). Use in connection handler to reject, or in event handler to kick user.',
278
- '- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect` are not available (no socket). Use `emitToUser`, `emitToRoom`, `emitToGateway`, `broadcast`.',
281
+ '- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect`, `emitToCurrentRoom`, and `broadcastToRoom` are not available (no bound socket). Use `emitToUser`, `emitToRoom(path, room, event, data)`, `emitToGateway`, and `broadcast`.',
279
282
  '- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
280
283
  '- **ACK + results (recommended UX):** client can emit an event with Socket.IO ack callback. Server immediately acks `{ queued: true, requestId, eventName }` (or `{ queued: false, error }`). The handler result is returned asynchronously via `ws:result` or `ws:error` with the same `requestId`.',
281
284
  '- **Client**: Browser apps must connect through the app/Nuxt Socket.IO bridge, not the hidden Enfyra backend. The backend gateway metadata path is the namespace, e.g. `/chat`. A third app should connect with `io("/chat", { path: "/socket.io", withCredentials: true })` and proxy `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. Direct Enfyra app clients may connect with `io("/ws/chat", { path: "/ws/socket.io", withCredentials: true })` when using the built-in bridge convention.',
@@ -300,8 +303,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
300
303
  '- **Safety**: Max nesting depth 10 (flow triggering flow). Circular flow detection prevents A→B→A loops. HTTP steps: **SSRF hardening** — only `http`/`https`; blocks `localhost`, private IPs, and hostnames resolving to private IPs (use internet-facing URLs like `https://api.example.com`, not internal services, unless server policy changes). Default HTTP timeout 30s (AbortController). `$trigger()` available inside flow steps.',
301
304
  '- **Workflow**: Create flow → `create_record` on `flow_definition`. Add steps → `create_record` on `flow_step_definition` with `flow: {id}`. For branch steps, set `parent: {id: conditionStepId}` and `branch: "true"` or `"false"`. Trigger manually via `POST /admin/flow/trigger/{flowId}`.',
302
305
  '- **Flow source sanity:** after creating or patching a multi-step flow, refetch saved `flow_step_definition` rows and verify every script/condition step has executable step-specific `sourceCode` with a body/return. A helper-only source block that parses successfully is still broken and must be patched before the user sees it in eApp.',
303
- '- **Test step**: `POST /admin/flow/test-step` with body `{type, config, timeout}` — runs a single step without saving, returns `{success, result, error, duration}`.',
304
- '- MCP wrappers: use **`test_flow_step`** for one step, **`run_admin_test`** with `kind:"flow_step"` for the generic admin tester, and **`trigger_flow`** for saved flows.',
306
+ '- **Test step**: `POST /admin/test/run` with body `{kind:"flow_step", type, config, timeout}` — runs a single step without saving, returns `{success, result, error, duration}`.',
307
+ '- MCP wrappers: use **`test_flow_step`** for one flow step, **`run_admin_test`** for flow/websocket tests, and **`trigger_flow`** for saved flows.',
305
308
  '- **In handlers/hooks**: Trigger flows via `$ctx.$trigger("flow-name", {payload})` or `$ctx.$trigger(flowId, {payload})`.',
306
309
  '- Before writing flow scripts, call **`discover_script_contexts`** to confirm `@FLOW`, `@FLOW_PAYLOAD`, `@FLOW_LAST`, `#table_name`, `$ctx.$trigger`, and `$socket` behavior.',
307
310
  '',
@@ -433,6 +436,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
433
436
  `- \`discover_runtime_context\` → GET metadata/routes/method/runtime-backed tables and infer live primary key/backend context`,
434
437
  `- \`discover_query_capabilities\` → GET metadata/routes and summarize Query DSL/deep/table-specific query contracts`,
435
438
  `- \`discover_script_contexts\` → static runtime macro/context map for handlers/hooks/flows/websocket/GraphQL/extensions`,
439
+ `- \`trace_metadata_usage\` → scans live metadata and script-backed records for a table/route/keyword before editing`,
440
+ `- \`get_script_source\` → GET one script-backed record via filter+limit=1 and returns full sourceCode plus sha256`,
441
+ `- \`patch_script_source\` → preview or apply exact sourceCode search/replace; apply validates with \`${base}/admin/script/validate\`, then PATCHes \`${base}/<script_table>/<id>\``,
436
442
  `- \`query_table\` → GET \`${base}/<tableName>?…\` (query string from tool args, including filter/sort/page/limit/fields plus optional meta/deep/aggregate)`,
437
443
  `- \`count_records\` → GET \`${base}/<tableName>?fields=id&limit=1&meta=totalCount|filterCount\``,
438
444
  `- \`find_one_record\` (by id) → GET \`${base}/<tableName>?filter=…&limit=1\``,
@@ -443,7 +449,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
443
449
  `- \`create_extension\` → POST \`${base}/extension_definition\` (Vue SFC only; for \`type="page"\` pass menuId, for \`type="widget"\` or \`type="global"\` omit menuId). \`update_record\` on extension_definition to change code.`,
444
450
  `- Flow tables: \`${base}/flow_definition\`, \`${base}/flow_step_definition\`, \`${base}/flow_execution_definition\` — use standard CRUD tools.`,
445
451
  `- \`run_admin_test\` → POST \`${base}/admin/test/run\``,
446
- `- \`test_flow_step\` → POST \`${base}/admin/flow/test-step\``,
452
+ `- \`test_flow_step\` → POST \`${base}/admin/test/run\` with \`kind:"flow_step"\``,
447
453
  `- \`trigger_flow\` → POST \`${base}/admin/flow/trigger/<flowIdOrName>\``,
448
454
  `- Other: \`${base}/menu_definition\`, \`${base}/websocket_definition\`, \`${base}/admin/reload\`, etc.`,
449
455
  '',
@@ -44,8 +44,8 @@ export function routeAvailableMethodNames(route, methodIdNameMap = {}) {
44
44
  return methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
45
45
  }
46
46
 
47
- export function routePublishedMethodNames(route, methodIdNameMap = {}) {
48
- return methodNamesFromRecords(route?.publishedMethods || [], methodIdNameMap);
47
+ export function routePublicMethodNames(route, methodIdNameMap = {}) {
48
+ return methodNamesFromRecords(route?.publicMethods || [], methodIdNameMap);
49
49
  }
50
50
 
51
51
  export function permissionMethodNames(permission, methodIdNameMap = {}) {
@@ -148,7 +148,7 @@ export function summarizeRouteAccess(route, routePermissions, methodIdNameMap =
148
148
  path: route?.path,
149
149
  isEnabled: route?.isEnabled !== false,
150
150
  availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
151
- publishedMethods: routePublishedMethodNames(route, methodIdNameMap),
151
+ publicMethods: routePublicMethodNames(route, methodIdNameMap),
152
152
  skipRoleGuardMethods: methodNamesFromRecords(route?.skipRoleGuardMethods || [], methodIdNameMap),
153
153
  permissions,
154
154
  expected: expectedMethods.length ? {
@@ -8,6 +8,7 @@ config();
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
10
  import { z } from 'zod';
11
+ import { createHash } from 'node:crypto';
11
12
 
12
13
  // Configuration
13
14
  const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
@@ -27,7 +28,7 @@ import {
27
28
  normalizeMethodNames,
28
29
  resolveRoleByNameOrId,
29
30
  routeAvailableMethodNames,
30
- routePublishedMethodNames,
31
+ routePublicMethodNames,
31
32
  summarizeRouteAccess,
32
33
  summarizeRoutePermission,
33
34
  validateMethodsForRoute,
@@ -127,6 +128,24 @@ const FIELD_PERMISSION_CONDITION_OPERATORS = [
127
128
  '_not',
128
129
  ];
129
130
 
131
+ const SCRIPT_BACKED_TABLES = [
132
+ 'route_handler_definition',
133
+ 'pre_hook_definition',
134
+ 'post_hook_definition',
135
+ 'flow_step_definition',
136
+ 'websocket_event_definition',
137
+ 'websocket_definition',
138
+ 'gql_definition',
139
+ 'bootstrap_script_definition',
140
+ ];
141
+
142
+ const SCRIPT_SOURCE_FIELDS = [
143
+ 'sourceCode',
144
+ 'handlerScript',
145
+ 'connectionHandlerScript',
146
+ 'code',
147
+ ];
148
+
130
149
  function normalizeTables(metadata) {
131
150
  const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
132
151
  return Array.isArray(tablesSource)
@@ -209,7 +228,7 @@ function summarizeRoutes(routesResult) {
209
228
  path: route.path,
210
229
  mainTable: route.mainTable?.name || route.mainTableName || null,
211
230
  availableMethods: (route.availableMethods || []).map((method) => method.name).filter(Boolean),
212
- publishedMethods: (route.publishedMethods || []).map((method) => method.name).filter(Boolean),
231
+ publicMethods: (route.publicMethods || []).map((method) => method.name).filter(Boolean),
213
232
  isEnabled: route.isEnabled,
214
233
  }));
215
234
  }
@@ -403,6 +422,126 @@ function normalizeHexColorInput(value, fieldName) {
403
422
  return color;
404
423
  }
405
424
 
425
+ function sha256(value) {
426
+ return createHash('sha256').update(String(value), 'utf8').digest('hex');
427
+ }
428
+
429
+ function getScriptSourceField(record) {
430
+ for (const field of SCRIPT_SOURCE_FIELDS) {
431
+ if (typeof record?.[field] === 'string') return field;
432
+ }
433
+ if (record?.config && typeof record.config === 'object' && typeof record.config.code === 'string') {
434
+ return 'config.code';
435
+ }
436
+ return null;
437
+ }
438
+
439
+ function getRecordSource(record) {
440
+ const field = getScriptSourceField(record);
441
+ if (!field) return { field: null, sourceCode: '' };
442
+ if (field === 'config.code') return { field, sourceCode: record.config.code };
443
+ return { field, sourceCode: record[field] };
444
+ }
445
+
446
+ async function fetchRecordByPrimaryKey(tableName, id, fields = '*') {
447
+ const primaryKey = await getPrimaryFieldName(tableName);
448
+ const query = new URLSearchParams({
449
+ filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
450
+ limit: '1',
451
+ fields,
452
+ });
453
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`);
454
+ const record = unwrapData(result)[0] || null;
455
+ if (!record) throw new Error(`${tableName} record ${id} was not found.`);
456
+ return { primaryKey, record };
457
+ }
458
+
459
+ async function fetchScriptRecord(tableName, id) {
460
+ validateTableName(tableName);
461
+ if (!SCRIPT_BACKED_TABLES.includes(tableName)) {
462
+ throw new Error(`Unsupported script-backed table "${tableName}". Supported: ${SCRIPT_BACKED_TABLES.join(', ')}`);
463
+ }
464
+ const { primaryKey, record } = await fetchRecordByPrimaryKey(tableName, id, '*');
465
+ const { field, sourceCode } = getRecordSource(record);
466
+ if (!field) {
467
+ throw new Error(`${tableName} record ${id} does not expose a known editable source field.`);
468
+ }
469
+ return { primaryKey, record, sourceField: field, sourceCode };
470
+ }
471
+
472
+ function countOccurrences(source, needle) {
473
+ if (!needle) return 0;
474
+ let count = 0;
475
+ let index = 0;
476
+ while (true) {
477
+ index = source.indexOf(needle, index);
478
+ if (index === -1) return count;
479
+ count += 1;
480
+ index += needle.length;
481
+ }
482
+ }
483
+
484
+ function replaceOccurrence(source, oldText, newText, mode) {
485
+ const occurrences = countOccurrences(source, oldText);
486
+ if (occurrences === 0) {
487
+ throw new Error('oldText was not found in the current source.');
488
+ }
489
+ if (mode === 'first') {
490
+ return {
491
+ occurrences,
492
+ patched: source.replace(oldText, newText),
493
+ replaced: 1,
494
+ };
495
+ }
496
+ return {
497
+ occurrences,
498
+ patched: source.split(oldText).join(newText),
499
+ replaced: occurrences,
500
+ };
501
+ }
502
+
503
+ function sourcePreview(source, aroundText) {
504
+ if (!aroundText) return source.slice(0, 1200);
505
+ const index = source.indexOf(aroundText);
506
+ if (index === -1) return source.slice(0, 1200);
507
+ const start = Math.max(0, index - 500);
508
+ const end = Math.min(source.length, index + aroundText.length + 500);
509
+ return `${start > 0 ? '...' : ''}${source.slice(start, end)}${end < source.length ? '...' : ''}`;
510
+ }
511
+
512
+ function scriptRecordLabel(tableName, record) {
513
+ const method = record.method?.name || record.method?.method || null;
514
+ const route = record.route?.path || null;
515
+ const flow = record.flow?.name || null;
516
+ const gateway = record.gateway?.path || null;
517
+ const gqlTable = record.table?.name || null;
518
+ return {
519
+ tableName,
520
+ id: getId(record),
521
+ key: record.key || record.name || record.eventName || null,
522
+ route,
523
+ method,
524
+ flow,
525
+ gateway,
526
+ gqlTable,
527
+ };
528
+ }
529
+
530
+ function scriptTraceFields(tableName) {
531
+ const common = 'id,_id,name,key,eventName,sourceCode,handlerScript,connectionHandlerScript,code,scriptLanguage';
532
+ const byTable = {
533
+ route_handler_definition: `${common},route.id,route.path,method.id,method.name`,
534
+ pre_hook_definition: `${common},route.id,route.path,methods.id,methods.name,isGlobal`,
535
+ post_hook_definition: `${common},route.id,route.path,methods.id,methods.name,isGlobal`,
536
+ flow_step_definition: `${common},flow.id,flow.name`,
537
+ websocket_event_definition: `${common},gateway.id,gateway.path`,
538
+ websocket_definition: `${common},path`,
539
+ gql_definition: `${common},table.id,table.name`,
540
+ bootstrap_script_definition: common,
541
+ };
542
+ return byTable[tableName] || '*';
543
+ }
544
+
406
545
  async function findMethodRecordByName(method) {
407
546
  const filter = encodeURIComponent(JSON.stringify({ name: { _eq: method } }));
408
547
  const result = await fetchAPI(ENFYRA_API_URL, `/method_definition?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
@@ -483,7 +622,7 @@ server.tool(
483
622
  async () => {
484
623
  const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
485
624
  const [routesResult, methodsResult] = await Promise.all([
486
- fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*&limit=1000'),
625
+ fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publicMethods.*&limit=1000'),
487
626
  fetchAPI(ENFYRA_API_URL, '/method_definition?limit=100'),
488
627
  ]);
489
628
 
@@ -513,7 +652,7 @@ server.tool(
513
652
  })),
514
653
  rest: {
515
654
  routePattern: 'Dynamic REST routes expose GET/POST at /<route-path> and PATCH/DELETE at /<route-path>/:id; there is no GET /<route-path>/:id.',
516
- publicAccess: 'publishedMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
655
+ publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
517
656
  routeTables: routeTableList,
518
657
  noRouteTables,
519
658
  canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
@@ -532,14 +671,14 @@ server.tool(
532
671
  },
533
672
  adminTesting: {
534
673
  runAdminTest: 'run_admin_test wraps POST /admin/test/run for flow_step, websocket_event, and websocket_connection scripts.',
535
- testFlowStep: 'test_flow_step wraps POST /admin/flow/test-step.',
674
+ testFlowStep: 'test_flow_step also wraps POST /admin/test/run with kind=flow_step.',
536
675
  triggerFlow: 'trigger_flow wraps POST /admin/flow/trigger/:id and enqueues a flow execution.',
537
676
  },
538
677
  graphql: {
539
678
  endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
540
679
  schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
541
680
  enablement: 'A table appears in GraphQL when gql_definition has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
542
- auth: 'GraphQL currently requires Authorization: Bearer <accessToken>; REST publishedMethods does not make GraphQL anonymous.',
681
+ auth: 'GraphQL currently requires Authorization: Bearer <accessToken>; REST publicMethods does not make GraphQL anonymous.',
543
682
  management: routeTables.has('gql_definition')
544
683
  ? 'Use update_table graphqlEnabled or create/update records on gql_definition, then reload_graphql if needed.'
545
684
  : 'Use update_table graphqlEnabled, then reload_graphql if needed.',
@@ -572,7 +711,7 @@ server.tool(
572
711
  settingsResult,
573
712
  meResult,
574
713
  ] = await Promise.all([
575
- fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled&limit=1000'),
714
+ fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000'),
576
715
  fetchAPI(ENFYRA_API_URL, '/method_definition?limit=100'),
577
716
  fetchAPI(ENFYRA_API_URL, '/gql_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
578
717
  fetchAPI(ENFYRA_API_URL, '/flow_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
@@ -586,7 +725,7 @@ server.tool(
586
725
  const routes = summarizeRoutes(routesResult);
587
726
  const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
588
727
  const adminRoutes = routes.filter((route) => route.path?.startsWith('/admin'));
589
- const publicRoutes = routes.filter((route) => route.publishedMethods?.length);
728
+ const publicRoutes = routes.filter((route) => route.publicMethods?.length);
590
729
 
591
730
  const payload = {
592
731
  apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
@@ -614,7 +753,7 @@ server.tool(
614
753
  publicRoutes: publicRoutes.map((route) => ({
615
754
  path: route.path,
616
755
  mainTable: route.mainTable,
617
- publishedMethods: route.publishedMethods,
756
+ publicMethods: route.publicMethods,
618
757
  })),
619
758
  },
620
759
  cacheAndCluster: {
@@ -647,7 +786,7 @@ server.tool(
647
786
  },
648
787
  async ({ tableName }) => {
649
788
  const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
650
- const routesResult = await fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled&limit=1000');
789
+ const routesResult = await fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
651
790
  const tables = normalizeTables(metadata);
652
791
  const routes = summarizeRoutes(routesResult);
653
792
  const table = tableName ? tables.find((item) => item.name === tableName) : null;
@@ -804,7 +943,7 @@ server.tool(
804
943
  graphqlResolver: {
805
944
  runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
806
945
  data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
807
- caveat: 'REST publishedMethods do not make GraphQL anonymous.',
946
+ caveat: 'REST publicMethods do not make GraphQL anonymous.',
808
947
  },
809
948
  extensionVueSfc: {
810
949
  runs: 'Frontend extension code, not server sandbox.',
@@ -820,7 +959,7 @@ server.tool(
820
959
  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.',
821
960
  countPattern: 'To count records in custom code, do not fetch full rows. Use const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); then read result.meta.filterCount or result.meta.totalCount.',
822
961
  },
823
- socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect because there is no bound socket.',
962
+ socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect/emitToCurrentRoom/broadcastToRoom because there is no bound socket. emitToRoom requires an explicit gateway path: emitToRoom(path, room, event, data).',
824
963
  packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
825
964
  files: 'Upload helpers are on $storage; raw create_record on file_definition is not equivalent to multipart upload/storage rollback. For multipart request files, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from disk-backed temp storage. Use @STORAGE.$registerFile only when the object already exists in storage and the script should create the file_definition record without uploading bytes. Use buffer only for small generated files.',
826
965
  },
@@ -843,7 +982,7 @@ server.tool(
843
982
  [
844
983
  'Returns the resolved API base URL for this MCP session (env ENFYRA_API_URL).',
845
984
  'Use when the user asks which HTTP endpoint or full URL applies: combine enfyraApiUrl with paths from server instructions (GET/POST /{table}, PATCH/DELETE /{table}/{id}, no GET /{table}/{id}).',
846
- 'Auth: publishedMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
985
+ 'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
847
986
  'If path might differ from table name, use get_all_routes before asserting a URL.',
848
987
  'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
849
988
  'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via gql_definition/update_table graphqlEnabled and send Bearer auth.',
@@ -862,8 +1001,8 @@ server.tool(
862
1001
  oneRowById: `${base}/<table_name>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`,
863
1002
  },
864
1003
  auth: {
865
- publishedMethods: 'If the HTTP method is published for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
866
- graphql: 'GraphQL currently requires Bearer auth; route publishedMethods do not make GraphQL anonymous.',
1004
+ publicMethods: 'If the HTTP method is public for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
1005
+ graphql: 'GraphQL currently requires Bearer auth; route publicMethods do not make GraphQL anonymous.',
867
1006
  mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
868
1007
  },
869
1008
  pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
@@ -1055,6 +1194,100 @@ server.tool('update_record', 'Update an existing record by ID using PATCH. The t
1055
1194
  }, null, 2) }] };
1056
1195
  });
1057
1196
 
1197
+ server.tool(
1198
+ 'get_script_source',
1199
+ [
1200
+ 'Fetch the full editable source for one script-backed metadata record without preview truncation.',
1201
+ 'Use this before reviewing or patching long handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts.',
1202
+ ].join(' '),
1203
+ {
1204
+ tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to read'),
1205
+ id: z.string().describe('Record ID to read'),
1206
+ },
1207
+ async ({ tableName, id }) => {
1208
+ const { primaryKey, record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1209
+ return { content: [{ type: 'text', text: JSON.stringify({
1210
+ tableName,
1211
+ id,
1212
+ primaryKey,
1213
+ sourceField,
1214
+ sourceCode,
1215
+ sourceLength: sourceCode.length,
1216
+ sourceSha256: sha256(sourceCode),
1217
+ scriptLanguage: record.scriptLanguage || record.language || null,
1218
+ record: scriptRecordLabel(tableName, record),
1219
+ }, null, 2) }] };
1220
+ },
1221
+ );
1222
+
1223
+ server.tool(
1224
+ 'patch_script_source',
1225
+ [
1226
+ 'Patch sourceCode on a script-backed record using exact search/replace with optional hash checking.',
1227
+ 'By default this returns a preview only. Set apply=true to validate through /admin/script/validate and save.',
1228
+ 'Use get_script_source first for long scripts, then patch only the exact block you intend to change.',
1229
+ ].join(' '),
1230
+ {
1231
+ tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to patch'),
1232
+ id: z.string().describe('Record ID to patch'),
1233
+ oldText: z.string().describe('Exact text to replace'),
1234
+ newText: z.string().describe('Replacement text'),
1235
+ occurrence: z.enum(['first', 'all']).optional().default('all').describe('Replace first occurrence or all occurrences.'),
1236
+ expectedSourceSha256: z.string().optional().describe('Optional SHA-256 from get_script_source; fails if source changed.'),
1237
+ scriptLanguage: z.string().optional().describe('Script language to save. Defaults to existing scriptLanguage or javascript.'),
1238
+ apply: z.boolean().optional().default(false).describe('false returns preview only; true validates and saves.'),
1239
+ },
1240
+ async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply }) => {
1241
+ const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1242
+ if (sourceField !== 'sourceCode') {
1243
+ throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_record intentionally for this legacy field.`);
1244
+ }
1245
+ const beforeHash = sha256(sourceCode);
1246
+ if (expectedSourceSha256 && expectedSourceSha256 !== beforeHash) {
1247
+ throw new Error(`Source hash mismatch. Current sha256 is ${beforeHash}; re-read with get_script_source before patching.`);
1248
+ }
1249
+ const { occurrences, patched, replaced } = replaceOccurrence(sourceCode, oldText, newText, occurrence || 'all');
1250
+ const afterHash = sha256(patched);
1251
+ const payload = {
1252
+ action: apply ? 'patch_script_source_applied' : 'patch_script_source_preview',
1253
+ tableName,
1254
+ id,
1255
+ sourceField,
1256
+ sourceLengthBefore: sourceCode.length,
1257
+ sourceLengthAfter: patched.length,
1258
+ sourceSha256Before: beforeHash,
1259
+ sourceSha256After: afterHash,
1260
+ occurrences,
1261
+ replaced,
1262
+ preview: {
1263
+ before: sourcePreview(sourceCode, oldText),
1264
+ after: sourcePreview(patched, newText),
1265
+ },
1266
+ next: apply ? undefined : 'Call patch_script_source again with apply=true and expectedSourceSha256 set to sourceSha256Before to validate and save.',
1267
+ };
1268
+ if (!apply) {
1269
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1270
+ }
1271
+ const language = scriptLanguage || record.scriptLanguage || 'javascript';
1272
+ const prepared = await prepareGenericMutation(
1273
+ tableName,
1274
+ JSON.stringify({ sourceCode: patched, scriptLanguage: language }),
1275
+ );
1276
+ const result = await fetchAPI(
1277
+ ENFYRA_API_URL,
1278
+ `/${tableName}/${encodeURIComponent(String(id))}`,
1279
+ { method: 'PATCH', body: JSON.stringify(prepared.payload) },
1280
+ );
1281
+ return { content: [{ type: 'text', text: JSON.stringify({
1282
+ ...payload,
1283
+ ...summarizeMutationResult(result, 'patch_script_source_applied', tableName),
1284
+ id,
1285
+ scriptLanguage: language,
1286
+ scriptValidation: prepared.scriptValidation,
1287
+ }, null, 2) }] };
1288
+ },
1289
+ );
1290
+
1058
1291
  server.tool(
1059
1292
  'update_script_source',
1060
1293
  [
@@ -1308,7 +1541,7 @@ server.tool(
1308
1541
 
1309
1542
  server.tool(
1310
1543
  'test_flow_step',
1311
- 'Test a single flow step without saving it. Wraps POST /admin/flow/test-step.',
1544
+ 'Test a single flow step without saving it. Wraps POST /admin/test/run with kind=flow_step.',
1312
1545
  {
1313
1546
  type: z.enum(['script', 'condition', 'query', 'create', 'update', 'delete', 'http', 'trigger_flow', 'sleep', 'log']).describe('Flow step type'),
1314
1547
  config: z.string().describe('Step config as JSON string'),
@@ -1324,9 +1557,9 @@ server.tool(
1324
1557
  ...(key ? { key } : {}),
1325
1558
  ...(mockFlow ? { mockFlow: JSON.parse(mockFlow) } : {}),
1326
1559
  };
1327
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/flow/test-step', {
1560
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
1328
1561
  method: 'POST',
1329
- body: JSON.stringify(body),
1562
+ body: JSON.stringify({ ...body, kind: 'flow_step' }),
1330
1563
  });
1331
1564
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1332
1565
  },
@@ -1473,13 +1706,13 @@ function enrichRoute(route, state) {
1473
1706
  method: method.name || state.methodIdNameMap[String(getId(method))] || null,
1474
1707
  }))
1475
1708
  : route.availableMethods,
1476
- publishedMethods: Array.isArray(route.publishedMethods)
1477
- ? route.publishedMethods.map((method) => ({
1709
+ publicMethods: Array.isArray(route.publicMethods)
1710
+ ? route.publicMethods.map((method) => ({
1478
1711
  ...method,
1479
1712
  name: method.name || state.methodIdNameMap[String(getId(method))] || null,
1480
1713
  method: method.name || state.methodIdNameMap[String(getId(method))] || null,
1481
1714
  }))
1482
- : route.publishedMethods,
1715
+ : route.publicMethods,
1483
1716
  skipRoleGuardMethods: Array.isArray(route.skipRoleGuardMethods)
1484
1717
  ? route.skipRoleGuardMethods.map((method) => ({
1485
1718
  ...method,
@@ -1555,7 +1788,7 @@ server.tool(
1555
1788
  'inspect_route',
1556
1789
  [
1557
1790
  'REST-first inspection for a route/path. Use before changing handlers, hooks, permissions, guards, or testing an endpoint.',
1558
- 'Returns the backing table, available/published methods, handlers, hooks, route permissions, guards, and exact REST URL pattern.',
1791
+ 'Returns the backing table, available/public methods, handlers, hooks, route permissions, guards, and exact REST URL pattern.',
1559
1792
  ].join(' '),
1560
1793
  {
1561
1794
  path: z.string().optional().describe('Route path, e.g. /user_definition'),
@@ -1639,6 +1872,92 @@ server.tool(
1639
1872
  },
1640
1873
  );
1641
1874
 
1875
+ server.tool(
1876
+ 'trace_metadata_usage',
1877
+ [
1878
+ 'Trace where a table, route path, keyword, or script fragment appears across live metadata and script-backed records.',
1879
+ 'Use this before changing production flows/handlers/hooks to find all callers or writers for a table such as cloud_provisioning_history.',
1880
+ ].join(' '),
1881
+ {
1882
+ query: z.string().describe('Table name, route path, field name, event name, or source-code keyword to trace'),
1883
+ includeSourcePreview: z.boolean().optional().default(true).describe('Include short source previews around matches.'),
1884
+ limit: z.number().optional().default(25).describe('Maximum matches per section.'),
1885
+ },
1886
+ async ({ query, includeSourcePreview, limit }) => {
1887
+ const q = String(query || '').trim();
1888
+ if (!q) throw new Error('query is required.');
1889
+ const lower = q.toLowerCase();
1890
+ const max = Math.max(1, Math.min(Number(limit || 25), 100));
1891
+ const state = await collectRestDefinitionState();
1892
+ const contains = (value) => JSON.stringify(value ?? '').toLowerCase().includes(lower);
1893
+ const sourceContains = (record) => getRecordSource(record).sourceCode.toLowerCase().includes(lower);
1894
+
1895
+ const scriptTableResults = await Promise.all(SCRIPT_BACKED_TABLES.map(async (tableName) => {
1896
+ const fields = scriptTraceFields(tableName);
1897
+ let result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=${encodeURIComponent(fields)}`).catch((error) => ({ error }));
1898
+ if (result?.error && fields !== '*') {
1899
+ result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=*`).catch((error) => ({ error }));
1900
+ }
1901
+ return { tableName, records: unwrapData(result), error: result?.error?.message || null };
1902
+ }));
1903
+ const scriptMatches = [];
1904
+ const scriptErrors = [];
1905
+ for (const { tableName, records, error } of scriptTableResults) {
1906
+ if (error) {
1907
+ scriptErrors.push({ tableName, error });
1908
+ continue;
1909
+ }
1910
+ for (const record of records) {
1911
+ const { field, sourceCode } = getRecordSource(record);
1912
+ if (!field || !sourceContains(record)) continue;
1913
+ scriptMatches.push({
1914
+ ...scriptRecordLabel(tableName, record),
1915
+ sourceField: field,
1916
+ sourceLength: sourceCode.length,
1917
+ sourceSha256: sha256(sourceCode),
1918
+ preview: includeSourcePreview ? sourcePreview(sourceCode, q) : undefined,
1919
+ });
1920
+ }
1921
+ }
1922
+
1923
+ const tableMatches = state.tables.filter((table) => contains({
1924
+ name: table.name,
1925
+ alias: table.alias,
1926
+ description: table.description,
1927
+ columns: (table.columns || []).map((column) => ({ name: column.name, type: column.type, description: column.description })),
1928
+ relations: (table.relations || []).map((relation) => ({ propertyName: relation.propertyName, type: relation.type, description: relation.description })),
1929
+ }));
1930
+ const routeMatches = state.routes.filter((route) => contains({
1931
+ path: route.path,
1932
+ mainTable: route.mainTable,
1933
+ description: route.description,
1934
+ }));
1935
+ const fieldPermissionMatches = state.fieldPermissions.filter((permission) => contains(permission));
1936
+ const guardMatches = state.guards.filter((guard) => contains(guard));
1937
+ const routePermissionMatches = state.routePermissions.filter((permission) => contains(permission));
1938
+
1939
+ return { content: [{ type: 'text', text: JSON.stringify({
1940
+ query: q,
1941
+ counts: {
1942
+ tables: tableMatches.length,
1943
+ routes: routeMatches.length,
1944
+ scripts: scriptMatches.length,
1945
+ fieldPermissions: fieldPermissionMatches.length,
1946
+ routePermissions: routePermissionMatches.length,
1947
+ guards: guardMatches.length,
1948
+ },
1949
+ tables: tableMatches.map(summarizeTable).slice(0, max),
1950
+ routes: routeMatches.map((route) => enrichRoute(route, state)).slice(0, max),
1951
+ scripts: scriptMatches.slice(0, max),
1952
+ fieldPermissions: fieldPermissionMatches.slice(0, max),
1953
+ routePermissions: routePermissionMatches.slice(0, max),
1954
+ guards: guardMatches.slice(0, max),
1955
+ scriptReadErrors: scriptErrors,
1956
+ next: 'Use inspect_route/inspect_table for structure, get_script_source for full source, and patch_script_source for exact validated edits.',
1957
+ }, null, 2) }] };
1958
+ },
1959
+ );
1960
+
1642
1961
  server.tool(
1643
1962
  'test_rest_endpoint',
1644
1963
  [
@@ -1651,7 +1970,7 @@ server.tool(
1651
1970
  query: z.string().optional().describe('Optional query params JSON object, merged onto path query string'),
1652
1971
  body: z.string().optional().describe('Optional JSON request body string'),
1653
1972
  headers: z.string().optional().describe('Optional headers JSON object'),
1654
- useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test published/public access.'),
1973
+ useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test public access.'),
1655
1974
  },
1656
1975
  async ({ method, path, query, body, headers, useAuth }) => {
1657
1976
  const httpMethod = normalizeMethodNameInput(method || 'GET');
@@ -1711,7 +2030,7 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
1711
2030
  const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
1712
2031
  const queryParams = new URLSearchParams({
1713
2032
  filter: JSON.stringify(filter),
1714
- fields: 'id,path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled',
2033
+ fields: 'id,path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled',
1715
2034
  limit: '1000',
1716
2035
  });
1717
2036
  const result = await fetchAPI(ENFYRA_API_URL, `/route_definition?${queryParams.toString()}`);
@@ -1745,7 +2064,7 @@ server.tool(
1745
2064
  '**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
1746
2065
  '`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/reports/summary`, `/auth/login`, or any custom path.',
1747
2066
  'Do NOT create a new table_definition only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
1748
- 'availableMethods = which REST verbs the route responds to. publishedMethods = which REST verbs are public (no auth). GraphQL is enabled separately through gql_definition/update_table graphqlEnabled.',
2067
+ 'availableMethods = which REST verbs the route responds to. publicMethods = which REST verbs are public (no auth). GraphQL is enabled separately through gql_definition/update_table graphqlEnabled.',
1749
2068
  'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
1750
2069
  'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
1751
2070
  ].join(' '),
@@ -1754,12 +2073,12 @@ server.tool(
1754
2073
  mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
1755
2074
  methods: z.array(z.string())
1756
2075
  .describe('HTTP method names this route supports (availableMethods). Each value must exist in method_definition.name. Common: ["GET","POST","PATCH","DELETE"].'),
1757
- publishedMethods: z.array(z.string()).optional()
2076
+ publicMethods: z.array(z.string()).optional()
1758
2077
  .describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
1759
2078
  isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
1760
2079
  description: z.string().optional().describe('Route description'),
1761
2080
  },
1762
- async ({ path: routePath, mainTableId, methods, publishedMethods, isEnabled, description }) => {
2081
+ async ({ path: routePath, mainTableId, methods, publicMethods, isEnabled, description }) => {
1763
2082
  const methodMap = await getMethodMap();
1764
2083
  const normalizedPath = normalizeRestPath(routePath);
1765
2084
 
@@ -1776,8 +2095,8 @@ server.tool(
1776
2095
  body.mainTable = { id: mainTableId };
1777
2096
  }
1778
2097
 
1779
- if (publishedMethods && publishedMethods.length > 0) {
1780
- body.publishedMethods = resolveMethodIds(methodMap, publishedMethods);
2098
+ if (publicMethods && publicMethods.length > 0) {
2099
+ body.publicMethods = resolveMethodIds(methodMap, publicMethods);
1781
2100
  }
1782
2101
 
1783
2102
  const result = await fetchAPI(ENFYRA_API_URL, '/route_definition', {
@@ -1795,7 +2114,7 @@ server.tool(
1795
2114
  path: created?.path,
1796
2115
  mainTableId: mainTableId ?? null,
1797
2116
  availableMethods: methods,
1798
- publishedMethods: publishedMethods || [],
2117
+ publicMethods: publicMethods || [],
1799
2118
  },
1800
2119
  routeReload,
1801
2120
  next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET", sourceCode }) for custom code. Create extra method_definition.name rows first for custom methods such as PUT.`,
@@ -2060,7 +2379,7 @@ server.tool(
2060
2379
  'create_route_permission',
2061
2380
  [
2062
2381
  'Create route access permission for a route and REST methods.',
2063
- 'Use this when a non-root role/user should access an authenticated route. publishedMethods are for public access; route permissions are for authenticated role/user access.',
2382
+ 'Use this when a non-root role/user should access an authenticated route. publicMethods are for public access; route permissions are for authenticated role/user access.',
2064
2383
  ].join(' '),
2065
2384
  {
2066
2385
  path: z.string().optional().describe('Route path, e.g. /user_definition'),
@@ -2109,7 +2428,7 @@ server.tool(
2109
2428
  'audit_route_access',
2110
2429
  [
2111
2430
  'Audit route access for one or more routes.',
2112
- 'Use this before granting access or debugging 403s. It reports available methods, public published methods, skipRoleGuard methods, route permissions, and optional missing methods for one role/user scope.',
2431
+ 'Use this before granting access or debugging 403s. It reports available methods, public methods, skipRoleGuard methods, route permissions, and optional missing methods for one role/user scope.',
2113
2432
  ].join(' '),
2114
2433
  {
2115
2434
  path: z.string().optional().describe('Exact route path, e.g. /orders'),
@@ -2147,7 +2466,7 @@ server.tool(
2147
2466
  const expectedMethods = normalizeMethodNames(methods || []);
2148
2467
  const payload = {
2149
2468
  guidance: {
2150
- publicAccess: 'publishedMethods bypass RoleGuard and do not require route_permission_definition.',
2469
+ publicAccess: 'publicMethods bypass RoleGuard and do not require route_permission_definition.',
2151
2470
  authenticatedAccess: 'For non-public methods, eApp PermissionGate and backend RoleGuard both expect enabled route_permission_definition rows with matching route + HTTP method.',
2152
2471
  directUserAccess: 'allowedRoutePermissions on /me represent direct user-scoped route permissions; role.routePermissions represent role-scoped permissions.',
2153
2472
  },
@@ -2216,8 +2535,8 @@ server.tool(
2216
2535
  const existingMethods = existing ? summarizeRoutePermission(existing, methodIdNameMap).methods : [];
2217
2536
  const finalMethods = mergeMethodNames(existingMethods, requestedMethods, mode);
2218
2537
  const methodRefs = resolveMethodIds(methodMap, finalMethods);
2219
- const publishedMethods = routePublishedMethodNames(route, methodIdNameMap);
2220
- const alreadyPublic = requestedMethods.filter((method) => publishedMethods.includes(method));
2538
+ const publicMethods = routePublicMethodNames(route, methodIdNameMap);
2539
+ const alreadyPublic = requestedMethods.filter((method) => publicMethods.includes(method));
2221
2540
 
2222
2541
  let result;
2223
2542
  let action;
@@ -2257,7 +2576,7 @@ server.tool(
2257
2576
  id: getId(route),
2258
2577
  path: route.path,
2259
2578
  availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
2260
- publishedMethods,
2579
+ publicMethods,
2261
2580
  },
2262
2581
  scope: {
2263
2582
  role: role ? { id: getId(role), name: role.name } : null,