@enfyra/mcp-server 0.0.77 → 0.0.78

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.78",
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,107 @@ 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
+ return {
517
+ tableName,
518
+ id: getId(record),
519
+ key: record.key || record.name || record.eventName || null,
520
+ route,
521
+ method,
522
+ flow,
523
+ };
524
+ }
525
+
406
526
  async function findMethodRecordByName(method) {
407
527
  const filter = encodeURIComponent(JSON.stringify({ name: { _eq: method } }));
408
528
  const result = await fetchAPI(ENFYRA_API_URL, `/method_definition?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
@@ -483,7 +603,7 @@ server.tool(
483
603
  async () => {
484
604
  const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
485
605
  const [routesResult, methodsResult] = await Promise.all([
486
- fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*&limit=1000'),
606
+ fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publicMethods.*&limit=1000'),
487
607
  fetchAPI(ENFYRA_API_URL, '/method_definition?limit=100'),
488
608
  ]);
489
609
 
@@ -513,7 +633,7 @@ server.tool(
513
633
  })),
514
634
  rest: {
515
635
  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.',
636
+ publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
517
637
  routeTables: routeTableList,
518
638
  noRouteTables,
519
639
  canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
@@ -532,14 +652,14 @@ server.tool(
532
652
  },
533
653
  adminTesting: {
534
654
  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.',
655
+ testFlowStep: 'test_flow_step also wraps POST /admin/test/run with kind=flow_step.',
536
656
  triggerFlow: 'trigger_flow wraps POST /admin/flow/trigger/:id and enqueues a flow execution.',
537
657
  },
538
658
  graphql: {
539
659
  endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
540
660
  schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
541
661
  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.',
662
+ auth: 'GraphQL currently requires Authorization: Bearer <accessToken>; REST publicMethods does not make GraphQL anonymous.',
543
663
  management: routeTables.has('gql_definition')
544
664
  ? 'Use update_table graphqlEnabled or create/update records on gql_definition, then reload_graphql if needed.'
545
665
  : 'Use update_table graphqlEnabled, then reload_graphql if needed.',
@@ -572,7 +692,7 @@ server.tool(
572
692
  settingsResult,
573
693
  meResult,
574
694
  ] = await Promise.all([
575
- fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled&limit=1000'),
695
+ fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000'),
576
696
  fetchAPI(ENFYRA_API_URL, '/method_definition?limit=100'),
577
697
  fetchAPI(ENFYRA_API_URL, '/gql_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
578
698
  fetchAPI(ENFYRA_API_URL, '/flow_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
@@ -586,7 +706,7 @@ server.tool(
586
706
  const routes = summarizeRoutes(routesResult);
587
707
  const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
588
708
  const adminRoutes = routes.filter((route) => route.path?.startsWith('/admin'));
589
- const publicRoutes = routes.filter((route) => route.publishedMethods?.length);
709
+ const publicRoutes = routes.filter((route) => route.publicMethods?.length);
590
710
 
591
711
  const payload = {
592
712
  apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
@@ -614,7 +734,7 @@ server.tool(
614
734
  publicRoutes: publicRoutes.map((route) => ({
615
735
  path: route.path,
616
736
  mainTable: route.mainTable,
617
- publishedMethods: route.publishedMethods,
737
+ publicMethods: route.publicMethods,
618
738
  })),
619
739
  },
620
740
  cacheAndCluster: {
@@ -647,7 +767,7 @@ server.tool(
647
767
  },
648
768
  async ({ tableName }) => {
649
769
  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');
770
+ const routesResult = await fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
651
771
  const tables = normalizeTables(metadata);
652
772
  const routes = summarizeRoutes(routesResult);
653
773
  const table = tableName ? tables.find((item) => item.name === tableName) : null;
@@ -804,7 +924,7 @@ server.tool(
804
924
  graphqlResolver: {
805
925
  runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
806
926
  data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
807
- caveat: 'REST publishedMethods do not make GraphQL anonymous.',
927
+ caveat: 'REST publicMethods do not make GraphQL anonymous.',
808
928
  },
809
929
  extensionVueSfc: {
810
930
  runs: 'Frontend extension code, not server sandbox.',
@@ -820,7 +940,7 @@ server.tool(
820
940
  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
941
  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
942
  },
823
- socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect because there is no bound socket.',
943
+ 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
944
  packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
825
945
  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
946
  },
@@ -843,7 +963,7 @@ server.tool(
843
963
  [
844
964
  'Returns the resolved API base URL for this MCP session (env ENFYRA_API_URL).',
845
965
  '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.',
966
+ 'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
847
967
  'If path might differ from table name, use get_all_routes before asserting a URL.',
848
968
  '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
969
  'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via gql_definition/update_table graphqlEnabled and send Bearer auth.',
@@ -862,8 +982,8 @@ server.tool(
862
982
  oneRowById: `${base}/<table_name>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`,
863
983
  },
864
984
  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.',
985
+ publicMethods: 'If the HTTP method is public for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
986
+ graphql: 'GraphQL currently requires Bearer auth; route publicMethods do not make GraphQL anonymous.',
867
987
  mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
868
988
  },
869
989
  pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
@@ -1055,6 +1175,100 @@ server.tool('update_record', 'Update an existing record by ID using PATCH. The t
1055
1175
  }, null, 2) }] };
1056
1176
  });
1057
1177
 
1178
+ server.tool(
1179
+ 'get_script_source',
1180
+ [
1181
+ 'Fetch the full editable source for one script-backed metadata record without preview truncation.',
1182
+ 'Use this before reviewing or patching long handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts.',
1183
+ ].join(' '),
1184
+ {
1185
+ tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to read'),
1186
+ id: z.string().describe('Record ID to read'),
1187
+ },
1188
+ async ({ tableName, id }) => {
1189
+ const { primaryKey, record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1190
+ return { content: [{ type: 'text', text: JSON.stringify({
1191
+ tableName,
1192
+ id,
1193
+ primaryKey,
1194
+ sourceField,
1195
+ sourceCode,
1196
+ sourceLength: sourceCode.length,
1197
+ sourceSha256: sha256(sourceCode),
1198
+ scriptLanguage: record.scriptLanguage || record.language || null,
1199
+ record: scriptRecordLabel(tableName, record),
1200
+ }, null, 2) }] };
1201
+ },
1202
+ );
1203
+
1204
+ server.tool(
1205
+ 'patch_script_source',
1206
+ [
1207
+ 'Patch sourceCode on a script-backed record using exact search/replace with optional hash checking.',
1208
+ 'By default this returns a preview only. Set apply=true to validate through /admin/script/validate and save.',
1209
+ 'Use get_script_source first for long scripts, then patch only the exact block you intend to change.',
1210
+ ].join(' '),
1211
+ {
1212
+ tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to patch'),
1213
+ id: z.string().describe('Record ID to patch'),
1214
+ oldText: z.string().describe('Exact text to replace'),
1215
+ newText: z.string().describe('Replacement text'),
1216
+ occurrence: z.enum(['first', 'all']).optional().default('all').describe('Replace first occurrence or all occurrences.'),
1217
+ expectedSourceSha256: z.string().optional().describe('Optional SHA-256 from get_script_source; fails if source changed.'),
1218
+ scriptLanguage: z.string().optional().describe('Script language to save. Defaults to existing scriptLanguage or javascript.'),
1219
+ apply: z.boolean().optional().default(false).describe('false returns preview only; true validates and saves.'),
1220
+ },
1221
+ async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply }) => {
1222
+ const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1223
+ if (sourceField !== 'sourceCode') {
1224
+ throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_record intentionally for this legacy field.`);
1225
+ }
1226
+ const beforeHash = sha256(sourceCode);
1227
+ if (expectedSourceSha256 && expectedSourceSha256 !== beforeHash) {
1228
+ throw new Error(`Source hash mismatch. Current sha256 is ${beforeHash}; re-read with get_script_source before patching.`);
1229
+ }
1230
+ const { occurrences, patched, replaced } = replaceOccurrence(sourceCode, oldText, newText, occurrence || 'all');
1231
+ const afterHash = sha256(patched);
1232
+ const payload = {
1233
+ action: apply ? 'patch_script_source_applied' : 'patch_script_source_preview',
1234
+ tableName,
1235
+ id,
1236
+ sourceField,
1237
+ sourceLengthBefore: sourceCode.length,
1238
+ sourceLengthAfter: patched.length,
1239
+ sourceSha256Before: beforeHash,
1240
+ sourceSha256After: afterHash,
1241
+ occurrences,
1242
+ replaced,
1243
+ preview: {
1244
+ before: sourcePreview(sourceCode, oldText),
1245
+ after: sourcePreview(patched, newText),
1246
+ },
1247
+ next: apply ? undefined : 'Call patch_script_source again with apply=true and expectedSourceSha256 set to sourceSha256Before to validate and save.',
1248
+ };
1249
+ if (!apply) {
1250
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1251
+ }
1252
+ const language = scriptLanguage || record.scriptLanguage || 'javascript';
1253
+ const prepared = await prepareGenericMutation(
1254
+ tableName,
1255
+ JSON.stringify({ sourceCode: patched, scriptLanguage: language }),
1256
+ );
1257
+ const result = await fetchAPI(
1258
+ ENFYRA_API_URL,
1259
+ `/${tableName}/${encodeURIComponent(String(id))}`,
1260
+ { method: 'PATCH', body: JSON.stringify(prepared.payload) },
1261
+ );
1262
+ return { content: [{ type: 'text', text: JSON.stringify({
1263
+ ...payload,
1264
+ ...summarizeMutationResult(result, 'patch_script_source_applied', tableName),
1265
+ id,
1266
+ scriptLanguage: language,
1267
+ scriptValidation: prepared.scriptValidation,
1268
+ }, null, 2) }] };
1269
+ },
1270
+ );
1271
+
1058
1272
  server.tool(
1059
1273
  'update_script_source',
1060
1274
  [
@@ -1308,7 +1522,7 @@ server.tool(
1308
1522
 
1309
1523
  server.tool(
1310
1524
  'test_flow_step',
1311
- 'Test a single flow step without saving it. Wraps POST /admin/flow/test-step.',
1525
+ 'Test a single flow step without saving it. Wraps POST /admin/test/run with kind=flow_step.',
1312
1526
  {
1313
1527
  type: z.enum(['script', 'condition', 'query', 'create', 'update', 'delete', 'http', 'trigger_flow', 'sleep', 'log']).describe('Flow step type'),
1314
1528
  config: z.string().describe('Step config as JSON string'),
@@ -1324,9 +1538,9 @@ server.tool(
1324
1538
  ...(key ? { key } : {}),
1325
1539
  ...(mockFlow ? { mockFlow: JSON.parse(mockFlow) } : {}),
1326
1540
  };
1327
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/flow/test-step', {
1541
+ const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
1328
1542
  method: 'POST',
1329
- body: JSON.stringify(body),
1543
+ body: JSON.stringify({ ...body, kind: 'flow_step' }),
1330
1544
  });
1331
1545
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1332
1546
  },
@@ -1473,13 +1687,13 @@ function enrichRoute(route, state) {
1473
1687
  method: method.name || state.methodIdNameMap[String(getId(method))] || null,
1474
1688
  }))
1475
1689
  : route.availableMethods,
1476
- publishedMethods: Array.isArray(route.publishedMethods)
1477
- ? route.publishedMethods.map((method) => ({
1690
+ publicMethods: Array.isArray(route.publicMethods)
1691
+ ? route.publicMethods.map((method) => ({
1478
1692
  ...method,
1479
1693
  name: method.name || state.methodIdNameMap[String(getId(method))] || null,
1480
1694
  method: method.name || state.methodIdNameMap[String(getId(method))] || null,
1481
1695
  }))
1482
- : route.publishedMethods,
1696
+ : route.publicMethods,
1483
1697
  skipRoleGuardMethods: Array.isArray(route.skipRoleGuardMethods)
1484
1698
  ? route.skipRoleGuardMethods.map((method) => ({
1485
1699
  ...method,
@@ -1555,7 +1769,7 @@ server.tool(
1555
1769
  'inspect_route',
1556
1770
  [
1557
1771
  '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.',
1772
+ 'Returns the backing table, available/public methods, handlers, hooks, route permissions, guards, and exact REST URL pattern.',
1559
1773
  ].join(' '),
1560
1774
  {
1561
1775
  path: z.string().optional().describe('Route path, e.g. /user_definition'),
@@ -1639,6 +1853,88 @@ server.tool(
1639
1853
  },
1640
1854
  );
1641
1855
 
1856
+ server.tool(
1857
+ 'trace_metadata_usage',
1858
+ [
1859
+ 'Trace where a table, route path, keyword, or script fragment appears across live metadata and script-backed records.',
1860
+ 'Use this before changing production flows/handlers/hooks to find all callers or writers for a table such as cloud_provisioning_history.',
1861
+ ].join(' '),
1862
+ {
1863
+ query: z.string().describe('Table name, route path, field name, event name, or source-code keyword to trace'),
1864
+ includeSourcePreview: z.boolean().optional().default(true).describe('Include short source previews around matches.'),
1865
+ limit: z.number().optional().default(25).describe('Maximum matches per section.'),
1866
+ },
1867
+ async ({ query, includeSourcePreview, limit }) => {
1868
+ const q = String(query || '').trim();
1869
+ if (!q) throw new Error('query is required.');
1870
+ const lower = q.toLowerCase();
1871
+ const max = Math.max(1, Math.min(Number(limit || 25), 100));
1872
+ const state = await collectRestDefinitionState();
1873
+ const contains = (value) => JSON.stringify(value ?? '').toLowerCase().includes(lower);
1874
+ const sourceContains = (record) => getRecordSource(record).sourceCode.toLowerCase().includes(lower);
1875
+
1876
+ const scriptTableResults = await Promise.all(SCRIPT_BACKED_TABLES.map(async (tableName) => {
1877
+ const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=*`).catch((error) => ({ error }));
1878
+ return { tableName, records: unwrapData(result), error: result?.error?.message || null };
1879
+ }));
1880
+ const scriptMatches = [];
1881
+ const scriptErrors = [];
1882
+ for (const { tableName, records, error } of scriptTableResults) {
1883
+ if (error) {
1884
+ scriptErrors.push({ tableName, error });
1885
+ continue;
1886
+ }
1887
+ for (const record of records) {
1888
+ const { field, sourceCode } = getRecordSource(record);
1889
+ if (!field || !sourceContains(record)) continue;
1890
+ scriptMatches.push({
1891
+ ...scriptRecordLabel(tableName, record),
1892
+ sourceField: field,
1893
+ sourceLength: sourceCode.length,
1894
+ sourceSha256: sha256(sourceCode),
1895
+ preview: includeSourcePreview ? sourcePreview(sourceCode, q) : undefined,
1896
+ });
1897
+ }
1898
+ }
1899
+
1900
+ const tableMatches = state.tables.filter((table) => contains({
1901
+ name: table.name,
1902
+ alias: table.alias,
1903
+ description: table.description,
1904
+ columns: (table.columns || []).map((column) => ({ name: column.name, type: column.type, description: column.description })),
1905
+ relations: (table.relations || []).map((relation) => ({ propertyName: relation.propertyName, type: relation.type, description: relation.description })),
1906
+ }));
1907
+ const routeMatches = state.routes.filter((route) => contains({
1908
+ path: route.path,
1909
+ mainTable: route.mainTable,
1910
+ description: route.description,
1911
+ }));
1912
+ const fieldPermissionMatches = state.fieldPermissions.filter((permission) => contains(permission));
1913
+ const guardMatches = state.guards.filter((guard) => contains(guard));
1914
+ const routePermissionMatches = state.routePermissions.filter((permission) => contains(permission));
1915
+
1916
+ return { content: [{ type: 'text', text: JSON.stringify({
1917
+ query: q,
1918
+ counts: {
1919
+ tables: tableMatches.length,
1920
+ routes: routeMatches.length,
1921
+ scripts: scriptMatches.length,
1922
+ fieldPermissions: fieldPermissionMatches.length,
1923
+ routePermissions: routePermissionMatches.length,
1924
+ guards: guardMatches.length,
1925
+ },
1926
+ tables: tableMatches.map(summarizeTable).slice(0, max),
1927
+ routes: routeMatches.map((route) => enrichRoute(route, state)).slice(0, max),
1928
+ scripts: scriptMatches.slice(0, max),
1929
+ fieldPermissions: fieldPermissionMatches.slice(0, max),
1930
+ routePermissions: routePermissionMatches.slice(0, max),
1931
+ guards: guardMatches.slice(0, max),
1932
+ scriptReadErrors: scriptErrors,
1933
+ next: 'Use inspect_route/inspect_table for structure, get_script_source for full source, and patch_script_source for exact validated edits.',
1934
+ }, null, 2) }] };
1935
+ },
1936
+ );
1937
+
1642
1938
  server.tool(
1643
1939
  'test_rest_endpoint',
1644
1940
  [
@@ -1651,7 +1947,7 @@ server.tool(
1651
1947
  query: z.string().optional().describe('Optional query params JSON object, merged onto path query string'),
1652
1948
  body: z.string().optional().describe('Optional JSON request body string'),
1653
1949
  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.'),
1950
+ useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test public access.'),
1655
1951
  },
1656
1952
  async ({ method, path, query, body, headers, useAuth }) => {
1657
1953
  const httpMethod = normalizeMethodNameInput(method || 'GET');
@@ -1711,7 +2007,7 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
1711
2007
  const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
1712
2008
  const queryParams = new URLSearchParams({
1713
2009
  filter: JSON.stringify(filter),
1714
- fields: 'id,path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled',
2010
+ fields: 'id,path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled',
1715
2011
  limit: '1000',
1716
2012
  });
1717
2013
  const result = await fetchAPI(ENFYRA_API_URL, `/route_definition?${queryParams.toString()}`);
@@ -1745,7 +2041,7 @@ server.tool(
1745
2041
  '**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
1746
2042
  '`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
2043
  '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.',
2044
+ '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
2045
  'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
1750
2046
  '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
2047
  ].join(' '),
@@ -1754,12 +2050,12 @@ server.tool(
1754
2050
  mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
1755
2051
  methods: z.array(z.string())
1756
2052
  .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()
2053
+ publicMethods: z.array(z.string()).optional()
1758
2054
  .describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
1759
2055
  isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
1760
2056
  description: z.string().optional().describe('Route description'),
1761
2057
  },
1762
- async ({ path: routePath, mainTableId, methods, publishedMethods, isEnabled, description }) => {
2058
+ async ({ path: routePath, mainTableId, methods, publicMethods, isEnabled, description }) => {
1763
2059
  const methodMap = await getMethodMap();
1764
2060
  const normalizedPath = normalizeRestPath(routePath);
1765
2061
 
@@ -1776,8 +2072,8 @@ server.tool(
1776
2072
  body.mainTable = { id: mainTableId };
1777
2073
  }
1778
2074
 
1779
- if (publishedMethods && publishedMethods.length > 0) {
1780
- body.publishedMethods = resolveMethodIds(methodMap, publishedMethods);
2075
+ if (publicMethods && publicMethods.length > 0) {
2076
+ body.publicMethods = resolveMethodIds(methodMap, publicMethods);
1781
2077
  }
1782
2078
 
1783
2079
  const result = await fetchAPI(ENFYRA_API_URL, '/route_definition', {
@@ -1795,7 +2091,7 @@ server.tool(
1795
2091
  path: created?.path,
1796
2092
  mainTableId: mainTableId ?? null,
1797
2093
  availableMethods: methods,
1798
- publishedMethods: publishedMethods || [],
2094
+ publicMethods: publicMethods || [],
1799
2095
  },
1800
2096
  routeReload,
1801
2097
  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 +2356,7 @@ server.tool(
2060
2356
  'create_route_permission',
2061
2357
  [
2062
2358
  '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.',
2359
+ '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
2360
  ].join(' '),
2065
2361
  {
2066
2362
  path: z.string().optional().describe('Route path, e.g. /user_definition'),
@@ -2109,7 +2405,7 @@ server.tool(
2109
2405
  'audit_route_access',
2110
2406
  [
2111
2407
  '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.',
2408
+ '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
2409
  ].join(' '),
2114
2410
  {
2115
2411
  path: z.string().optional().describe('Exact route path, e.g. /orders'),
@@ -2147,7 +2443,7 @@ server.tool(
2147
2443
  const expectedMethods = normalizeMethodNames(methods || []);
2148
2444
  const payload = {
2149
2445
  guidance: {
2150
- publicAccess: 'publishedMethods bypass RoleGuard and do not require route_permission_definition.',
2446
+ publicAccess: 'publicMethods bypass RoleGuard and do not require route_permission_definition.',
2151
2447
  authenticatedAccess: 'For non-public methods, eApp PermissionGate and backend RoleGuard both expect enabled route_permission_definition rows with matching route + HTTP method.',
2152
2448
  directUserAccess: 'allowedRoutePermissions on /me represent direct user-scoped route permissions; role.routePermissions represent role-scoped permissions.',
2153
2449
  },
@@ -2216,8 +2512,8 @@ server.tool(
2216
2512
  const existingMethods = existing ? summarizeRoutePermission(existing, methodIdNameMap).methods : [];
2217
2513
  const finalMethods = mergeMethodNames(existingMethods, requestedMethods, mode);
2218
2514
  const methodRefs = resolveMethodIds(methodMap, finalMethods);
2219
- const publishedMethods = routePublishedMethodNames(route, methodIdNameMap);
2220
- const alreadyPublic = requestedMethods.filter((method) => publishedMethods.includes(method));
2515
+ const publicMethods = routePublicMethodNames(route, methodIdNameMap);
2516
+ const alreadyPublic = requestedMethods.filter((method) => publicMethods.includes(method));
2221
2517
 
2222
2518
  let result;
2223
2519
  let action;
@@ -2257,7 +2553,7 @@ server.tool(
2257
2553
  id: getId(route),
2258
2554
  path: route.path,
2259
2555
  availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
2260
- publishedMethods,
2556
+ publicMethods,
2261
2557
  },
2262
2558
  scope: {
2263
2559
  role: role ? { id: getId(role), name: role.name } : null,