@enfyra/mcp-server 0.0.76 → 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 +1 -1
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +4 -4
- package/src/lib/mcp-instructions.js +24 -18
- package/src/lib/route-permission-tools.js +3 -3
- package/src/mcp-server-entry.mjs +334 -38
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 `
|
|
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
package/src/lib/mcp-examples.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
'
|
|
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.
|
|
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,
|
|
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 (
|
|
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:** `"
|
|
122
|
-
'- **Method IDs** are instance data, not a stable contract. Query `method_definition` or use method names through MCP route helpers before setting `
|
|
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:** `"
|
|
125
|
-
'- **Right:** first query method records, then pass their ids, for example `"
|
|
126
|
-
'- **To unset:** pass empty array `"
|
|
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
|
|
135
|
-
'- Each route has **
|
|
136
|
-
'- If the **current request method** is listed in **
|
|
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
|
|
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 `
|
|
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 `
|
|
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
|
|
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/
|
|
304
|
-
'- MCP wrappers: use **`test_flow_step`** for one step, **`run_admin_test`**
|
|
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/
|
|
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
|
|
48
|
-
return methodNamesFromRecords(route?.
|
|
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
|
-
|
|
151
|
+
publicMethods: routePublicMethodNames(route, methodIdNameMap),
|
|
152
152
|
skipRoleGuardMethods: methodNamesFromRecords(route?.skipRoleGuardMethods || [], methodIdNameMap),
|
|
153
153
|
permissions,
|
|
154
154
|
expected: expectedMethods.length ? {
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.*,
|
|
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: '
|
|
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/
|
|
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
|
|
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.*,
|
|
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.
|
|
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
|
-
|
|
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.*,
|
|
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;
|
|
@@ -777,7 +897,7 @@ server.tool(
|
|
|
777
897
|
},
|
|
778
898
|
handler: {
|
|
779
899
|
runs: 'Main route logic, or canonical CRUD if no handler overrides.',
|
|
780
|
-
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main', '@REPOS
|
|
900
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main', '@REPOS.<table>', '@CACHE', '@HELPERS', '@STORAGE', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
|
|
781
901
|
queryContract: 'When a handler wraps a canonical table read, pass through client fields/deep/sort/page/limit/meta/aggregate/debugMode unless the route is a clearly custom summary or workflow endpoint.',
|
|
782
902
|
returnBehavior: 'Return value becomes response body unless post-hook changes it.',
|
|
783
903
|
},
|
|
@@ -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
|
|
927
|
+
caveat: 'REST publicMethods do not make GraphQL anonymous.',
|
|
808
928
|
},
|
|
809
929
|
extensionVueSfc: {
|
|
810
930
|
runs: 'Frontend extension code, not server sandbox.',
|
|
@@ -814,13 +934,13 @@ server.tool(
|
|
|
814
934
|
},
|
|
815
935
|
helpers: {
|
|
816
936
|
repos: {
|
|
817
|
-
scopes: '$repos.main
|
|
937
|
+
scopes: '$repos.main is the canonical repository for the route main table and preserves normal route query behavior. $repos.<table> is an explicit internal repository for table-specific logic. Do not generate $repos.secure.<table>; current runtime does not expose table methods there.',
|
|
818
938
|
mutationReturnShape: '$repos.<table>.create({ data }) and $repos.<table>.update({ id, data }) return a collection-shaped result: { data: [...], count? }. data is always an array for create/update, even for one created/updated record. If a script needs the single record object, it must read result.data[0] or result.data?.[0] ?? null.',
|
|
819
939
|
preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
|
|
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:
|
|
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
|
-
|
|
866
|
-
graphql: 'GraphQL currently requires Bearer auth; route
|
|
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/
|
|
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/
|
|
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
|
-
|
|
1477
|
-
? route.
|
|
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.
|
|
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/
|
|
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
|
|
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.*,
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
1780
|
-
body.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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: '
|
|
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
|
|
2220
|
-
const alreadyPublic = requestedMethods.filter((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
|
-
|
|
2556
|
+
publicMethods,
|
|
2261
2557
|
},
|
|
2262
2558
|
scope: {
|
|
2263
2559
|
role: role ? { id: getId(role), name: role.name } : null,
|