@enfyra/mcp-server 0.0.30 → 0.0.32
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/.codex/skills/enfyra-mcp-performance/SKILL.md +47 -0
- package/README.md +12 -6
- package/package.json +3 -2
- package/src/lib/mcp-examples.js +581 -0
- package/src/lib/mcp-instructions.js +36 -12
- package/src/lib/table-tools.js +31 -8
- package/src/mcp-server-entry.mjs +18 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Enfyra MCP Performance And Debug Mode
|
|
2
|
+
|
|
3
|
+
Use this skill when designing, reviewing, or debugging Enfyra apps through MCP where performance, read/write shape, indexes, websocket latency, RLS filters, or query correctness matter.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
- Verify capability with live metadata before claiming performance behavior. Use `inspect_table`, `inspect_route`, `discover_query_capabilities`, and `discover_runtime_context` first.
|
|
7
|
+
- Prefer indexed relation filters over scalar mirror columns. Relation property names can be used in `indexes` and `uniques`; Enfyra resolves them to physical FK columns.
|
|
8
|
+
- For hot read paths, design indexes with the most selective/user-scoped field first. Examples: `["member","is_read","conversation"]` for unread lookup and `["conversation","member","is_read"]` for mark-read updates.
|
|
9
|
+
- Use existence checks for UI dots and badges unless the user explicitly needs exact counts. Avoid count queries on every conversation row.
|
|
10
|
+
- Use `meta=filterCount` or MCP `count_records` only when count is the product requirement.
|
|
11
|
+
- For RLS hooks, mutate `@QUERY.filter` and preserve existing user filters with `_and`. `@QUERY.filter` is already `{}` when omitted.
|
|
12
|
+
- For dynamic scripts, keep runtime values in their original type. Do not wrap ids or payload values in `String(...)`, `Number(...)`, or `Boolean(...)`.
|
|
13
|
+
- For websocket apps, connect browsers through the Enfyra app/Nuxt bridge, not the hidden backend. Event handlers should be script-owned and use `@SOCKET` explicitly.
|
|
14
|
+
- Authenticated websocket gateways load `user_definition` once and expose it as `@USER`; do not ask clients to send their own `senderId`.
|
|
15
|
+
- Enfyra automatically joins authenticated sockets to `user_<userId>` after the connection script succeeds. App scripts should use this for `emitToUser` and should not re-join that room manually.
|
|
16
|
+
|
|
17
|
+
## Debug Workflow
|
|
18
|
+
1. Inspect the table schema, relations, indexes, and route permissions before changing code.
|
|
19
|
+
2. Reproduce with the smallest real request or Socket.IO event. Prefer `test_rest_endpoint` or `run_admin_test` when available.
|
|
20
|
+
3. If the problem is performance, state the exact query shape and expected index. Add or update `indexes` on `table_definition` through `create_table` or `update_table`.
|
|
21
|
+
4. Reload metadata/cache after schema or script changes when the tool does not do it automatically.
|
|
22
|
+
5. Retest the exact route/event and compare behavior before/after. Do not invent benchmark numbers.
|
|
23
|
+
|
|
24
|
+
## Chat App Review Checklist
|
|
25
|
+
- Confirm REST and Socket.IO both go through the app origin. REST uses the app proxy prefix; Socket.IO uses `/ws/<namespace>` and `path: "/ws/socket.io"` when the app bridge exposes that path.
|
|
26
|
+
- Confirm browser code never stores or forwards custom JWT cookies when the Enfyra app/proxy already manages cookies.
|
|
27
|
+
- Confirm `chat:join` queries `chat_conversation` visible to `@USER`, then joins `conversation:<id>`. Do not join rooms from raw membership member ids.
|
|
28
|
+
- Confirm `chat:message` uses `@SOCKET.broadcastToRoom("conversation:" + conversationId, ...)` and persists with `@REPOS` inside the event script. Do not add a flow just to save chat messages.
|
|
29
|
+
- Confirm new DM UX creates no empty conversation. A draft opens first; the first message calls a creation event that creates the conversation and message together.
|
|
30
|
+
- Confirm route-level RLS is server-side: pre-hooks merge membership filters into `@QUERY.filter` with `_and`; the client must not fetch all conversations then filter locally.
|
|
31
|
+
- Confirm conversation titles are computed from visible memberships from the current user's perspective. DM title should be the other person, not the current user.
|
|
32
|
+
- Confirm message history uses cursor pagination, newest messages first from the API then reversed for display; loading older messages preserves scroll.
|
|
33
|
+
- Confirm disconnect state disables chat input and shows a visible retry banner immediately.
|
|
34
|
+
- Confirm typing state is room-scoped, user-aware, and remains active while the input has text, even if the user pauses typing.
|
|
35
|
+
|
|
36
|
+
## Chat Read/Unread Pattern
|
|
37
|
+
- Use a join table, e.g. `chat_message_read`, with:
|
|
38
|
+
- `message` relation to `chat_message`
|
|
39
|
+
- `conversation` relation to `chat_conversation`
|
|
40
|
+
- `member` relation to `user_definition`
|
|
41
|
+
- `is_read` boolean
|
|
42
|
+
- `read_at` date nullable
|
|
43
|
+
- Add unique `["message","member"]`.
|
|
44
|
+
- Add indexes `["member","is_read","conversation"]` and `["conversation","member","is_read"]`.
|
|
45
|
+
- On message create, create read rows for conversation members. Sender rows start as read; other member rows start unread.
|
|
46
|
+
- On conversation open/read, update unread rows for `@USER` in that conversation to read.
|
|
47
|
+
- UI unread dots should check whether an unread row exists; count only when requested.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
MCP server for managing Enfyra instances from **Codex**, **Claude Code**, **Cursor**, and other MCP-compatible clients. All operations go through Enfyra's REST API.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
**LLM rules (REST, GraphQL, auth, URL, mutation `create_{tableName}`, etc.):** not in this README — see **`src/lib/mcp-instructions.js`** (content sent via MCP `instructions`) and tool descriptions in **`src/index.mjs`**. This README only covers **MCP installation and configuration** for users/devs.
|
|
6
|
+
**LLM rules (REST, GraphQL, auth, URL, mutation `create_{tableName}`, etc.):** not in this README — see **`src/lib/mcp-instructions.js`** (content sent via MCP `instructions`), **`src/lib/mcp-examples.js`** (concrete examples loaded through `get_enfyra_examples`), and tool descriptions in **`src/index.mjs`**. This README only covers **MCP installation and configuration** for users/devs.
|
|
7
7
|
|
|
8
8
|
**Official docs:** [Claude Code MCP](https://docs.anthropic.com/en/docs/claude-code/mcp) · [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings) · [Cursor MCP (`mcp.json`)](https://cursor.com/docs/context/mcp)
|
|
9
9
|
|
|
@@ -201,18 +201,24 @@ The Enfyra backend is private infrastructure. MCP, browser code, SSR routes, Gra
|
|
|
201
201
|
|
|
202
202
|
### SSR app auth pattern
|
|
203
203
|
|
|
204
|
-
When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra,
|
|
204
|
+
When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the Enfyra Cloud pattern:
|
|
205
205
|
|
|
206
|
-
- Browser code calls `{{ appOrigin }}/
|
|
207
|
-
-
|
|
208
|
-
-
|
|
206
|
+
- Browser code calls a same-origin proxy such as `{{ appOrigin }}/enfyra/**`, never the raw Enfyra backend URL.
|
|
207
|
+
- Nuxt can proxy it with `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**`, fetchOptions: { redirect: "manual" } } } }`. Keep redirects manual so OAuth set-cookie redirects reach the browser as real HTTP redirects with `Set-Cookie`.
|
|
208
|
+
- Generated apps should not create custom login/logout/me routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when the proxy is enough.
|
|
209
|
+
- Password login is `POST /enfyra/login`, not `/enfyra/auth/login`.
|
|
210
|
+
- Fetch the current user with `GET /enfyra/me` and logout with `POST /enfyra/logout`.
|
|
211
|
+
- OAuth starts through the same proxy prefix, for example `/enfyra/auth/google?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`. `redirect` must include the app origin, and `cookieBridgePrefix` is the same proxy prefix that reaches Enfyra API routes. Enfyra validates the redirect, exchanges OAuth on its callback, then redirects through `{redirect.origin}{cookieBridgePrefix}/auth/set-cookies` so the third app origin stores the cookies before returning to `redirect`.
|
|
212
|
+
- Socket.IO browser clients use a same-origin bridge too. Connect to the namespace, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, and proxy `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. The backend gateway metadata path remains `/chat`.
|
|
209
213
|
- Use token-query OAuth callback pages only for non-SSR/manual-token apps.
|
|
210
214
|
|
|
211
215
|
---
|
|
212
216
|
|
|
213
217
|
## Tools (summary)
|
|
214
218
|
|
|
215
|
-
Metadata, query/CRUD, route/handler/hook, tables/columns, reload cache, logs, user/roles, login, menu/extension, `get_enfyra_api_context`. For full tool list and behavior, see the app after enabling MCP or the source in `src/
|
|
219
|
+
Metadata, examples, query/CRUD, route/handler/hook, tables/columns, reload cache, logs, user/roles, login, menu/extension, `get_enfyra_api_context`. For full tool list and behavior, see the app after enabling MCP or the source in `src/mcp-server-entry.mjs`.
|
|
220
|
+
|
|
221
|
+
Use `get_enfyra_examples` when asking an LLM to generate concrete Enfyra implementation patterns. It returns categorized examples for SSR app auth/OAuth/proxy setup, schema/relations, queries/deep, handlers/hooks, permissions/RLS, websocket, flows, files, and extensions.
|
|
216
222
|
|
|
217
223
|
## Security
|
|
218
224
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enfyra/mcp-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "src/index.mjs",
|
|
8
8
|
"bin": "src/index.mjs",
|
|
9
9
|
"files": [
|
|
10
|
-
"src"
|
|
10
|
+
"src",
|
|
11
|
+
".codex/skills"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|
|
13
14
|
"start": "node src/index.mjs",
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
export const EXAMPLE_CATEGORIES = {
|
|
2
|
+
'ssr-app-auth': {
|
|
3
|
+
title: 'SSR app auth, OAuth, refresh, and proxy setup',
|
|
4
|
+
useWhen: 'Use when building Nuxt, Next, or another browser app that should rely on Enfyra cookies through an app-origin proxy.',
|
|
5
|
+
examples: [
|
|
6
|
+
{
|
|
7
|
+
name: 'Nuxt routeRules for REST and Socket.IO',
|
|
8
|
+
code: `export default defineNuxtConfig({
|
|
9
|
+
routeRules: {
|
|
10
|
+
"/enfyra/**": {
|
|
11
|
+
proxy: {
|
|
12
|
+
to: \`\${process.env.ENFYRA_API_URL}/**\`,
|
|
13
|
+
fetchOptions: { redirect: "manual" }
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"/socket.io/**": {
|
|
17
|
+
proxy: \`\${process.env.ENFYRA_APP_URL}/ws/socket.io/**\`
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
})`,
|
|
21
|
+
notes: [
|
|
22
|
+
'Browser code calls /enfyra/login, /enfyra/me, /enfyra/logout, and /enfyra/<table>.',
|
|
23
|
+
'Keep redirects manual so OAuth set-cookie redirects reach the browser.',
|
|
24
|
+
'Do not add custom token cookies when the proxy is enough.',
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'Next rewrites for REST and Socket.IO',
|
|
29
|
+
code: `const nextConfig = {
|
|
30
|
+
async rewrites() {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
source: "/enfyra/:path*",
|
|
34
|
+
destination: \`\${process.env.ENFYRA_API_URL}/:path*\`
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
source: "/socket.io/",
|
|
38
|
+
destination: \`\${process.env.ENFYRA_APP_URL}/ws/socket.io/\`
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default nextConfig`,
|
|
45
|
+
notes: [
|
|
46
|
+
'Use rewrites for browser traffic.',
|
|
47
|
+
'If you add Next middleware/proxy for auth gating, server-side checks may call the Enfyra API origin directly while forwarding the incoming Cookie header.',
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'Password login and current user fetch',
|
|
52
|
+
code: `await fetch("/enfyra/login", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
credentials: "include",
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ email, password, remember: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const me = await fetch("/enfyra/me", {
|
|
60
|
+
credentials: "include"
|
|
61
|
+
}).then((res) => res.ok ? res.json() : null)`,
|
|
62
|
+
notes: [
|
|
63
|
+
'Use /login, not /auth/login, for app/browser cookie login.',
|
|
64
|
+
'Do not read or store JWTs in browser JavaScript in proxy-cookie mode.',
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'Google OAuth button',
|
|
69
|
+
code: `const redirect = new URL("/chat", window.location.origin)
|
|
70
|
+
const url = new URL("/enfyra/auth/google", window.location.origin)
|
|
71
|
+
url.searchParams.set("redirect", redirect.toString())
|
|
72
|
+
url.searchParams.set("cookieBridgePrefix", "/enfyra")
|
|
73
|
+
window.location.href = url.toString()`,
|
|
74
|
+
notes: [
|
|
75
|
+
'redirect must be absolute and must include the app origin.',
|
|
76
|
+
'cookieBridgePrefix is the app proxy prefix that forwards to Enfyra API routes.',
|
|
77
|
+
'Enfyra redirects through {redirect.origin}{cookieBridgePrefix}/auth/set-cookies before returning to redirect.',
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
'schema-relations': {
|
|
83
|
+
title: 'Tables, columns, relations, cascade, and indexes',
|
|
84
|
+
useWhen: 'Use when creating or changing persisted data models.',
|
|
85
|
+
examples: [
|
|
86
|
+
{
|
|
87
|
+
name: 'Create a chat conversation table',
|
|
88
|
+
code: `create_table({
|
|
89
|
+
name: "chat_conversation",
|
|
90
|
+
columns: JSON.stringify([
|
|
91
|
+
{ name: "kind", type: "varchar", isNullable: false, defaultValue: "dm" },
|
|
92
|
+
{ name: "title", type: "varchar", isNullable: true },
|
|
93
|
+
{ name: "last_message_text", type: "text", isNullable: true },
|
|
94
|
+
{ name: "last_message_at", type: "datetime", isNullable: true }
|
|
95
|
+
])
|
|
96
|
+
})`,
|
|
97
|
+
notes: [
|
|
98
|
+
'create_table creates the default route for /chat_conversation.',
|
|
99
|
+
'Do not create tables just to get custom paths; use create_route for that.',
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'Create relations directly to user_definition',
|
|
104
|
+
code: `create_table({
|
|
105
|
+
name: "chat_message",
|
|
106
|
+
columns: JSON.stringify([
|
|
107
|
+
{ name: "text", type: "text", isNullable: false },
|
|
108
|
+
{ name: "persist_status", type: "varchar", defaultValue: "persisted" }
|
|
109
|
+
]),
|
|
110
|
+
relations: JSON.stringify([
|
|
111
|
+
{
|
|
112
|
+
propertyName: "conversation",
|
|
113
|
+
type: "many-to-one",
|
|
114
|
+
targetTable: { id: "<chat_conversation_id>" },
|
|
115
|
+
isNullable: false,
|
|
116
|
+
onDelete: "CASCADE"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
propertyName: "sender",
|
|
120
|
+
type: "many-to-one",
|
|
121
|
+
targetTable: { id: "<user_definition_id>" },
|
|
122
|
+
isNullable: false,
|
|
123
|
+
onDelete: "CASCADE"
|
|
124
|
+
}
|
|
125
|
+
]),
|
|
126
|
+
indexes: JSON.stringify([
|
|
127
|
+
["conversation", "createdAt"],
|
|
128
|
+
["sender", "createdAt"]
|
|
129
|
+
])
|
|
130
|
+
})`,
|
|
131
|
+
notes: [
|
|
132
|
+
'Use user_definition as the user table.',
|
|
133
|
+
'Do not add inverse relations on user_definition unless the user explicitly asks.',
|
|
134
|
+
'Do not provide physical FK column names; Enfyra derives them.',
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'Unread/read table with unique and indexes',
|
|
139
|
+
code: `create_table({
|
|
140
|
+
name: "chat_message_read",
|
|
141
|
+
columns: JSON.stringify([
|
|
142
|
+
{ name: "is_read", type: "boolean", defaultValue: false },
|
|
143
|
+
{ name: "read_at", type: "datetime", isNullable: true }
|
|
144
|
+
]),
|
|
145
|
+
relations: JSON.stringify([
|
|
146
|
+
{ propertyName: "message", type: "many-to-one", targetTable: { id: "<chat_message_id>" }, onDelete: "CASCADE" },
|
|
147
|
+
{ propertyName: "conversation", type: "many-to-one", targetTable: { id: "<chat_conversation_id>" }, onDelete: "CASCADE" },
|
|
148
|
+
{ propertyName: "member", type: "many-to-one", targetTable: { id: "<user_definition_id>" }, onDelete: "CASCADE" }
|
|
149
|
+
]),
|
|
150
|
+
uniques: JSON.stringify([["message", "member"]]),
|
|
151
|
+
indexes: JSON.stringify([
|
|
152
|
+
["member", "is_read", "conversation"],
|
|
153
|
+
["conversation", "member", "is_read"]
|
|
154
|
+
])
|
|
155
|
+
})`,
|
|
156
|
+
notes: [
|
|
157
|
+
'Unread is per user and per message; do not put global read state on conversation.',
|
|
158
|
+
'For chat-list UX, default to a boolean unread dot instead of exact counts.',
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
'queries-deep': {
|
|
164
|
+
title: 'REST queries, filters, meta counts, and deep relation fetches',
|
|
165
|
+
useWhen: 'Use when fetching records, filtering by relations, loading nested data, or counting efficiently.',
|
|
166
|
+
examples: [
|
|
167
|
+
{
|
|
168
|
+
name: 'List current user conversations through membership',
|
|
169
|
+
code: `GET /enfyra/chat_conversation_member?filter={
|
|
170
|
+
"member": { "id": { "_eq": "<currentUserId>" } }
|
|
171
|
+
}&deep={
|
|
172
|
+
"conversation": { "fields": "id,kind,title,last_message_text,last_message_at" }
|
|
173
|
+
}&limit=0`,
|
|
174
|
+
notes: [
|
|
175
|
+
'Use relation property names in filter and deep.',
|
|
176
|
+
'limit=0 means load all matching rows.',
|
|
177
|
+
'Do not fetch messages for every conversation on initial list load; load messages after selecting a conversation.',
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'Fetch one record by id',
|
|
182
|
+
code: `GET /enfyra/post?filter={"id":{"_eq":123}}&limit=1`,
|
|
183
|
+
notes: [
|
|
184
|
+
'There is no dynamic GET /<table>/<id> route.',
|
|
185
|
+
'Use filter + limit=1 or MCP find_one_record.',
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'Count without loading all rows',
|
|
190
|
+
code: `GET /enfyra/chat_message_read?fields=id&limit=1&meta=filterCount&filter={
|
|
191
|
+
"member": { "id": { "_eq": "<currentUserId>" } },
|
|
192
|
+
"is_read": { "_eq": false }
|
|
193
|
+
}`,
|
|
194
|
+
notes: [
|
|
195
|
+
'Use meta=totalCount with no filter and meta=filterCount with a filter.',
|
|
196
|
+
'Do not fetch all rows only to count them.',
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'Deep relation query',
|
|
201
|
+
code: `GET /enfyra/order?fields=id,total,customer&deep={
|
|
202
|
+
"customer": { "fields": "id,email,displayName" },
|
|
203
|
+
"items": {
|
|
204
|
+
"fields": "id,quantity,product",
|
|
205
|
+
"limit": 20,
|
|
206
|
+
"deep": {
|
|
207
|
+
"product": { "fields": "id,name,price" }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}`,
|
|
211
|
+
notes: [
|
|
212
|
+
'deep keys must be relation property names.',
|
|
213
|
+
'Allowed deep options are fields, filter, sort, limit, page, and deep.',
|
|
214
|
+
'Do not invent deep keys like members unless members is a relation on that table.',
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
'handlers-hooks': {
|
|
220
|
+
title: 'Custom handlers, pre-hooks, post-hooks, and script macros',
|
|
221
|
+
useWhen: 'Use when writing Enfyra dynamic JavaScript for REST behavior.',
|
|
222
|
+
examples: [
|
|
223
|
+
{
|
|
224
|
+
name: 'Custom register handler',
|
|
225
|
+
code: `const email = @BODY.email
|
|
226
|
+
const password = @BODY.password
|
|
227
|
+
|
|
228
|
+
if (!email || !password) @THROW400("Email and password are required")
|
|
229
|
+
|
|
230
|
+
const existing = await #user_definition.find({
|
|
231
|
+
filter: { email: { _eq: email } },
|
|
232
|
+
limit: 1
|
|
233
|
+
})
|
|
234
|
+
if (existing.data[0]) @THROW409("Email is already registered")
|
|
235
|
+
|
|
236
|
+
const result = await #user_definition.create({
|
|
237
|
+
data: {
|
|
238
|
+
email,
|
|
239
|
+
password: await @HELPERS.$bcrypt.hash(password)
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
return result.data?.[0] ?? null`,
|
|
244
|
+
notes: [
|
|
245
|
+
'create/update return { data: [...] }, not a bare row.',
|
|
246
|
+
'Use @THROW helpers for HTTP errors.',
|
|
247
|
+
'Prefer macros over raw $ctx when a macro exists.',
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'Pre-hook RLS filter merge',
|
|
252
|
+
code: `const incoming = @QUERY.filter || {}
|
|
253
|
+
const scope = {
|
|
254
|
+
memberships: {
|
|
255
|
+
member: { id: { _eq: @USER.id } }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@QUERY.filter = Object.keys(incoming).length
|
|
260
|
+
? { _and: [incoming, scope] }
|
|
261
|
+
: scope`,
|
|
262
|
+
notes: [
|
|
263
|
+
'@QUERY.filter is initialized as an object for REST pre-hooks.',
|
|
264
|
+
'Mutate @QUERY.filter before canonical CRUD runs.',
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'Post-hook response shaping',
|
|
269
|
+
code: `if (@ERROR) {
|
|
270
|
+
@LOGS("Request failed", @ERROR.message)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const row = Array.isArray(@DATA?.data) ? @DATA.data[0] : @DATA
|
|
275
|
+
if (row) {
|
|
276
|
+
row.displayTitle = row.title || row.email || String(row.id)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return @DATA`,
|
|
280
|
+
notes: [
|
|
281
|
+
'Post-hooks run after success and error paths.',
|
|
282
|
+
'Return non-undefined only when replacing the response body.',
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
'permissions-rls': {
|
|
288
|
+
title: 'Route permissions, guards, field permissions, column rules, and RLS',
|
|
289
|
+
useWhen: 'Use when securing routes or shaping what fields a user can read/write.',
|
|
290
|
+
examples: [
|
|
291
|
+
{
|
|
292
|
+
name: 'Publish read-only route',
|
|
293
|
+
code: `update_record({
|
|
294
|
+
tableName: "route_definition",
|
|
295
|
+
id: "<route_id>",
|
|
296
|
+
data: {
|
|
297
|
+
publishedMethods: [{ id: 1 }]
|
|
298
|
+
}
|
|
299
|
+
})`,
|
|
300
|
+
notes: [
|
|
301
|
+
'Method id 1 is GET. Use method_definition if you need to confirm method ids.',
|
|
302
|
+
'publishedMethods controls anonymous route access. Route permissions are not for public access.',
|
|
303
|
+
'Route permissions apply when the method is not public.',
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'Column rule for email format',
|
|
308
|
+
code: `create_column_rule({
|
|
309
|
+
tableName: "user_definition",
|
|
310
|
+
columnName: "email",
|
|
311
|
+
ruleType: "format",
|
|
312
|
+
ruleConfig: JSON.stringify({ format: "email" }),
|
|
313
|
+
message: "Please enter a valid email address"
|
|
314
|
+
})`,
|
|
315
|
+
notes: [
|
|
316
|
+
'Column rules validate canonical POST/PATCH body payloads.',
|
|
317
|
+
'Use column rules before writing custom validation code when the rule is simple.',
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: 'Field permission condition',
|
|
322
|
+
code: `create_field_permission({
|
|
323
|
+
tableName: "project",
|
|
324
|
+
fieldName: "internal_notes",
|
|
325
|
+
action: "read",
|
|
326
|
+
condition: JSON.stringify({
|
|
327
|
+
owner: { id: { _eq: "@USER.id" } }
|
|
328
|
+
})
|
|
329
|
+
})`,
|
|
330
|
+
notes: [
|
|
331
|
+
'Field permissions are for field-level access.',
|
|
332
|
+
'Use route/pre-hook filters for row-level access.',
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
websocket: {
|
|
338
|
+
title: 'Socket.IO gateways, events, rooms, and browser connection',
|
|
339
|
+
useWhen: 'Use when creating realtime features.',
|
|
340
|
+
examples: [
|
|
341
|
+
{
|
|
342
|
+
name: 'Browser client connection through app bridge',
|
|
343
|
+
code: `import { io } from "socket.io-client"
|
|
344
|
+
|
|
345
|
+
const socket = io("/chat", {
|
|
346
|
+
path: "/socket.io",
|
|
347
|
+
withCredentials: true,
|
|
348
|
+
transports: ["polling", "websocket"]
|
|
349
|
+
})`,
|
|
350
|
+
notes: [
|
|
351
|
+
'/chat is the Socket.IO namespace.',
|
|
352
|
+
'/socket.io is the app-origin transport path proxied to Enfyra app /ws/socket.io.',
|
|
353
|
+
'Do not connect browser code directly to the hidden backend.',
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: 'Connection script joins user presence room',
|
|
358
|
+
code: `if (!@USER?.id) {
|
|
359
|
+
@SOCKET.disconnect()
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@SOCKET.join(\`user_\${@USER.id}\`)
|
|
364
|
+
@SOCKET.reply("chat:ready", { userId: @USER.id })`,
|
|
365
|
+
notes: [
|
|
366
|
+
'Authenticated Enfyra sockets already load @USER.',
|
|
367
|
+
'Enfyra also joins user_<userId> for emitToUser delivery after connection succeeds.',
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'Chat join event',
|
|
372
|
+
code: `const conversationId = @BODY.conversationId
|
|
373
|
+
if (!conversationId) @THROW400("conversationId is required")
|
|
374
|
+
|
|
375
|
+
const membership = await #chat_conversation_member.find({
|
|
376
|
+
filter: {
|
|
377
|
+
conversation: { id: { _eq: conversationId } },
|
|
378
|
+
member: { id: { _eq: @USER.id } }
|
|
379
|
+
},
|
|
380
|
+
limit: 1
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
if (!membership.data[0]) @THROW403("Not a conversation member")
|
|
384
|
+
|
|
385
|
+
@SOCKET.join(\`conversation:\${conversationId}\`)
|
|
386
|
+
@SOCKET.reply("chat:joined", { conversationId })`,
|
|
387
|
+
notes: [
|
|
388
|
+
'Join conversation rooms, not member-id rooms.',
|
|
389
|
+
'Check membership server-side; do not trust the client.',
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'Chat message event with room broadcast and persistence',
|
|
394
|
+
code: `const { conversationId, text, clientId } = @BODY
|
|
395
|
+
if (!conversationId || !text) @THROW400("conversationId and text are required")
|
|
396
|
+
|
|
397
|
+
const membership = await #chat_conversation_member.find({
|
|
398
|
+
filter: {
|
|
399
|
+
conversation: { id: { _eq: conversationId } },
|
|
400
|
+
member: { id: { _eq: @USER.id } }
|
|
401
|
+
},
|
|
402
|
+
limit: 1
|
|
403
|
+
})
|
|
404
|
+
if (!membership.data[0]) @THROW403("Not a conversation member")
|
|
405
|
+
|
|
406
|
+
const created = await #chat_message.create({
|
|
407
|
+
data: {
|
|
408
|
+
conversation: { id: conversationId },
|
|
409
|
+
sender: { id: @USER.id },
|
|
410
|
+
text,
|
|
411
|
+
persist_status: "persisted"
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const message = created.data?.[0] ?? null
|
|
416
|
+
@SOCKET.emitToRoom(\`conversation:\${conversationId}\`, "chat:message", {
|
|
417
|
+
clientId,
|
|
418
|
+
message
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
return { ok: true, message }`,
|
|
422
|
+
notes: [
|
|
423
|
+
'Do not ask the client for senderId; use @USER.id.',
|
|
424
|
+
'Event scripts should explicitly emit replies/broadcasts.',
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
},
|
|
429
|
+
flows: {
|
|
430
|
+
title: 'Flows and step scripts',
|
|
431
|
+
useWhen: 'Use when automating background work or chaining steps.',
|
|
432
|
+
examples: [
|
|
433
|
+
{
|
|
434
|
+
name: 'Manual flow trigger from a post-hook',
|
|
435
|
+
code: `if (!@ERROR && @DATA?.data?.[0]) {
|
|
436
|
+
await @TRIGGER("send-welcome-email", {
|
|
437
|
+
userId: @DATA.data[0].id,
|
|
438
|
+
email: @DATA.data[0].email
|
|
439
|
+
})
|
|
440
|
+
}`,
|
|
441
|
+
notes: [
|
|
442
|
+
'Use flows for workflow semantics, retries, and history.',
|
|
443
|
+
'Do not use a flow just to persist a normal chat message.',
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: 'Flow condition step',
|
|
448
|
+
code: `const order = @FLOW_PAYLOAD.order
|
|
449
|
+
return order && order.total > 1000`,
|
|
450
|
+
notes: [
|
|
451
|
+
'Condition steps use JavaScript truthy/falsy.',
|
|
452
|
+
'Children run according to branch true/false.',
|
|
453
|
+
],
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: 'Flow query step config',
|
|
457
|
+
code: `{
|
|
458
|
+
"table": "user_definition",
|
|
459
|
+
"filter": { "email": { "_contains": "@example.com" } },
|
|
460
|
+
"limit": 50
|
|
461
|
+
}`,
|
|
462
|
+
notes: [
|
|
463
|
+
'Step configs are JSON; script steps use code strings.',
|
|
464
|
+
'Use public-safe URLs for HTTP steps.',
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
files: {
|
|
470
|
+
title: 'Files, folders, upload metadata, and assets',
|
|
471
|
+
useWhen: 'Use when handling uploads or returning uploaded files.',
|
|
472
|
+
examples: [
|
|
473
|
+
{
|
|
474
|
+
name: 'Upload a file from browser',
|
|
475
|
+
code: `const form = new FormData()
|
|
476
|
+
form.append("file", file)
|
|
477
|
+
form.append("folder", folderId)
|
|
478
|
+
form.append("title", "Invoice")
|
|
479
|
+
|
|
480
|
+
const uploaded = await fetch("/enfyra/files/upload", {
|
|
481
|
+
method: "POST",
|
|
482
|
+
credentials: "include",
|
|
483
|
+
body: form
|
|
484
|
+
}).then((res) => res.json())`,
|
|
485
|
+
notes: [
|
|
486
|
+
'Do not set Content-Type manually for FormData.',
|
|
487
|
+
'Use file routes/helpers instead of writing binary data into normal tables.',
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: 'Use uploaded file in handler',
|
|
492
|
+
code: `const file = $ctx.$uploadedFile
|
|
493
|
+
if (!file) @THROW400("File is required")
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
filename: file.originalname,
|
|
497
|
+
mimetype: file.mimetype,
|
|
498
|
+
size: file.size
|
|
499
|
+
}`,
|
|
500
|
+
notes: [
|
|
501
|
+
'Use file-specific context only in upload-capable routes.',
|
|
502
|
+
],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
extensions: {
|
|
507
|
+
title: 'Dynamic app extensions and menus',
|
|
508
|
+
useWhen: 'Use when adding custom UI pages to the Enfyra app.',
|
|
509
|
+
examples: [
|
|
510
|
+
{
|
|
511
|
+
name: 'Create menu then extension',
|
|
512
|
+
code: `create_menu({
|
|
513
|
+
title: "Reports",
|
|
514
|
+
path: "/reports",
|
|
515
|
+
icon: "i-lucide-bar-chart-3",
|
|
516
|
+
order: 20
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
create_extension({
|
|
520
|
+
name: "ReportsPage",
|
|
521
|
+
route: "/reports",
|
|
522
|
+
component: "<template><div>Reports</div></template>"
|
|
523
|
+
})`,
|
|
524
|
+
notes: [
|
|
525
|
+
'Menu provides navigation; extension provides content.',
|
|
526
|
+
'Extensions are Vue SFC records.',
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: 'Extension fetches Enfyra data',
|
|
531
|
+
code: `<script setup>
|
|
532
|
+
const { data, pending, refresh } = await useApi('/order_definition', {
|
|
533
|
+
query: {
|
|
534
|
+
limit: 10,
|
|
535
|
+
sort: '-createdAt'
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
</script>
|
|
539
|
+
|
|
540
|
+
<template>
|
|
541
|
+
<UButton :loading="pending" @click="refresh">Refresh</UButton>
|
|
542
|
+
<pre>{{ data }}</pre>
|
|
543
|
+
</template>`,
|
|
544
|
+
notes: [
|
|
545
|
+
'Use app-provided composables in extensions.',
|
|
546
|
+
'Keep extension UI focused; move backend logic into handlers/hooks when needed.',
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
export function listExampleCategories() {
|
|
554
|
+
return Object.entries(EXAMPLE_CATEGORIES).map(([key, value]) => ({
|
|
555
|
+
key,
|
|
556
|
+
title: value.title,
|
|
557
|
+
useWhen: value.useWhen,
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function getExamples(category) {
|
|
562
|
+
if (!category) {
|
|
563
|
+
return {
|
|
564
|
+
categories: listExampleCategories(),
|
|
565
|
+
hint: 'Call get_enfyra_examples with one category key to retrieve concrete examples for that area.',
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const entry = EXAMPLE_CATEGORIES[category];
|
|
570
|
+
if (!entry) {
|
|
571
|
+
return {
|
|
572
|
+
error: `Unknown example category "${category}"`,
|
|
573
|
+
categories: listExampleCategories(),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
category,
|
|
579
|
+
...entry,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
@@ -33,6 +33,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
33
33
|
'- 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`**.',
|
|
34
34
|
'- 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.',
|
|
35
35
|
'- 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.',
|
|
36
|
+
'- 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.',
|
|
36
37
|
'- 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.',
|
|
37
38
|
'- 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.',
|
|
38
39
|
'',
|
|
@@ -56,11 +57,13 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
56
57
|
'',
|
|
57
58
|
'### When the user asks how to connect a Nuxt/Next/SSR app to Enfyra',
|
|
58
59
|
'- This is guidance for the assistant to answer users and generate app code. It is not a separate MCP tool workflow.',
|
|
59
|
-
'-
|
|
60
|
-
'-
|
|
61
|
-
'-
|
|
62
|
-
'-
|
|
63
|
-
'-
|
|
60
|
+
'- Follow the Enfyra Cloud app pattern by default: expose one same-origin proxy prefix such as **`/enfyra/**`** and route it to the hidden Enfyra API base, e.g. Nuxt `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**`, fetchOptions: { redirect: "manual" } } } }`. Browser/generated app code calls `/enfyra/...`, not the raw Enfyra backend URL. Keep redirects manual so OAuth set-cookie redirects reach the browser with their `Set-Cookie` headers.',
|
|
61
|
+
'- Do **not** generate custom login/logout/me server routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when a Cloud-style proxy is enough. Let the proxied Enfyra API own its auth response and cookies.',
|
|
62
|
+
'- Password login in generated Cloud-style Nuxt code is **`POST /enfyra/login`**, not `/enfyra/auth/login` and not a custom SSR `/api/login` wrapper.',
|
|
63
|
+
'- Fetch the current user with **`GET /enfyra/me`** and logout with **`POST /enfyra/logout`**. Browser fetches stay same-origin and credentials/cookies flow through the proxy. Do not read JWTs in browser JavaScript for this mode.',
|
|
64
|
+
'- OAuth starts on the same proxy prefix, e.g. **`GET /enfyra/auth/{provider}?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`**. `redirect` must be an absolute `http(s)` URL with the app origin. `cookieBridgePrefix` is the third app proxy prefix that forwards to the Enfyra API; Enfyra normalizes it, so `enfyra`, `/enfyra`, and `/enfyra/` all mean `/enfyra`. Use token-query callback handling only when the app intentionally manages tokens itself.',
|
|
65
|
+
'- Socket.IO uses the app bridge too. Browser clients should connect to the gateway namespace with the Socket.IO transport path on the app origin, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, while Nuxt proxies `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. Do not connect browser code directly to the hidden backend Socket.IO endpoint.',
|
|
66
|
+
'- If a project explicitly standardizes on `/api/**` instead of `/enfyra/**`, keep the same Cloud-style behavior under that prefix: proxy to the Enfyra API and avoid generated cookie-management routes unless the user asks for a custom auth boundary.',
|
|
64
67
|
'- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server logs itself in with email/password against `{ENFYRA_API_URL}/auth/login`; for normal app work, `ENFYRA_API_URL` must still be the app proxy base such as `{{ nuxtApp }}/api`.',
|
|
65
68
|
'',
|
|
66
69
|
'### Routes vs tables (custom endpoints, handlers, hooks)',
|
|
@@ -75,15 +78,21 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
75
78
|
'### After a new table is created',
|
|
76
79
|
'- MCP **`create_table` supports creating columns and relations in the same call**: pass `columns` and `relations` as JSON arrays. Use `create_relation` only when adding a relation to an existing table later.',
|
|
77
80
|
'- MCP **`create_table` supports `isSingleRecord` directly**. Set `isSingleRecord: true` in the create call for settings/config tables that should keep only one record; do not create first and then patch only for this flag.',
|
|
81
|
+
'- MCP **`create_table` and `update_table` support `indexes` and `uniques`** as JSON arrays of logical field groups. Use compound indexes for hot filters and unread/read state, e.g. `indexes: [["member","is_read","conversation"],["conversation","member","is_read"]]` and `uniques: [["message","member"]]`. Relation property names are allowed; Enfyra resolves them to physical FK columns for SQL and Mongo.',
|
|
78
82
|
'- MCP **`create_table` does not accept `alias`**. Do not invent or send alias during table creation; default route/schema behavior is based on `name`. Use `update_table` later only when alias truly needs to change.',
|
|
79
83
|
'- In `create_table.relations`, each relation uses `targetTable` (table id or `{id}`), `type`, `propertyName`, optional `inversePropertyName` or `mappedBy`, `isNullable`, `onDelete`, and `description`. The target table must already exist.',
|
|
80
84
|
'- **Use `user_definition` as the only user table.** Do not create app-specific user/profile mapping tables such as `chat_profile`, `app_user`, `customer_user`, or tables that only mirror/link Enfyra users. If an app needs extra user fields or user relations, add columns/relations directly on `user_definition`.',
|
|
81
85
|
'- When modeling features that involve users, relate domain tables directly to `user_definition` through real Enfyra relations. Examples: `chat_conversation_member.member` → `user_definition`, `chat_message.sender` → `user_definition`, `order.customer` → `user_definition`. Do not create duplicate scalar columns like `userId` or separate profile records just to point back to a user.',
|
|
86
|
+
'- **Do not create reverse relations on `user_definition` by default.** For domain records that point to users, create only the owning relation on the domain table, e.g. `chat_message.sender -> user_definition`, and omit `inversePropertyName` unless the user explicitly asks for a reverse user field. Reverse fields like `user_definition.chatMessages`, `user_definition.orders`, or `user_definition.memberships` bloat user metadata and make `fields=*` user queries heavy.',
|
|
82
87
|
'- **Prefer real relations over relation-shaped columns.** If a field represents another record or list of records, model it as `relations`, not as columns such as `userId`, `author_id`, `categoryIds`, `teamIds`, `itemsJson`, or object/array JSON containing related records. Ask only when the user explicitly wants denormalized snapshot data.',
|
|
83
88
|
'- Common mapping: one owner record → `many-to-one`; one record has many children → define the child `many-to-one` and use inverse/read deep relation; peer/tag lists → `many-to-many`; one profile/settings row per parent → `one-to-one` when supported by the model.',
|
|
84
89
|
'- If the user asks to add a foreign key field, interpret it as a relation request unless they explicitly say they need a plain scalar column. Do not create both a relation and a duplicate scalar FK column for the same concept.',
|
|
85
90
|
'- **Never ask for or provide physical FK column names** when creating/updating relations. Do not include `fkCol`, `fkColumn`, `foreignKeyColumn`, `sourceColumn`, `targetColumn`, `junctionSourceColumn`, or `junctionTargetColumn` in create/update payloads unless you are only displaying existing metadata. Enfyra relation cascade derives physical FK/junction names from `propertyName` and table metadata, then hides FK columns from app form/schema definition.',
|
|
86
91
|
'- For relation CRUD payloads, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`.',
|
|
92
|
+
'- **Realtime/chat unread modeling:** unread/read is per user and per message. Do not put `read` or `lastRead` on `chat_conversation` globally. Prefer a join table such as `chat_message_read` with relations `message`, `conversation`, `member`, boolean `is_read`, nullable `read_at`, unique `["message","member"]`, and indexes `["member","is_read","conversation"]` plus `["conversation","member","is_read"]`. The UI can render a dot by checking existence of unread rows instead of counting every unread message.',
|
|
93
|
+
'- **Chat deletion modeling:** user-level delete/leave should remove the user from `chat_conversation_member` or otherwise make membership inactive. Do not add duplicated `deleted_at` state to both conversation and membership unless the product explicitly needs restore/audit behavior. A DM deleted for both sides is a membership operation for both members; a group is physically deleted only when no memberships remain.',
|
|
94
|
+
'- **Chat conversation title:** for DMs, compute the display title from the other visible member on the server/script response when possible. Do not trust the client to rename a DM from the current user perspective. Group titles can be generated from member display names until the product adds a custom group name.',
|
|
95
|
+
'- **Chat unread UI:** default to a boolean unread dot. Do not show unread counts unless the user explicitly asks for exact counts; exact counts require more query work and are not the default chat-list UX.',
|
|
87
96
|
'- Enfyra creates a **default** route at `/{table_name}` using the table **name** from `create_table` (not the alias). Prefer **`create_route`** for additional or custom paths instead of new tables.',
|
|
88
97
|
'- **Four REST HTTP operations** on that resource:',
|
|
89
98
|
` - **GET** \`${getList}\` — list / filter (query: filter, sort, page, limit, fields, meta).`,
|
|
@@ -119,10 +128,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
119
128
|
'',
|
|
120
129
|
'### Dynamic script syntax preference',
|
|
121
130
|
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.',
|
|
131
|
+
'- Do not coerce dynamic script values with `String(...)`, `Number(...)`, or `Boolean(...)`. Enfyra payloads, user ids, record ids, and relation ids should keep their runtime type; validate required values and pass them through directly.',
|
|
122
132
|
'- Use raw `$ctx` only when there is no template macro for the field or helper you need.',
|
|
123
133
|
'- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
|
|
124
134
|
'- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
|
|
125
135
|
'',
|
|
136
|
+
'### Chat / realtime app rules learned from implementation review',
|
|
137
|
+
'- Before generating a chat app, inspect live metadata for `websocket_definition`, `websocket_event_definition`, `chat_conversation`, `chat_conversation_member`, and `chat_message`. Do not assume table names, reverse relations, route permissions, or physical field casing.',
|
|
138
|
+
'- For browser SSR apps, REST goes through the app proxy prefix and Socket.IO goes through an app-origin Socket.IO transport proxy. Third apps should connect to the namespace, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, and proxy `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. Do not connect app browser code directly to the hidden backend. Do not add custom token cookies when the Enfyra app/proxy already owns cookies.',
|
|
139
|
+
'- On an authenticated gateway, Enfyra loads `user_definition` once for the socket and event scripts receive `@USER`. The server also joins `user_<userId>` after the connection script succeeds. Event scripts should not ask the client to send `senderId`, and `chat:join` does not need to join `user_<userId>` again.',
|
|
140
|
+
'- Use `chat:join` only for conversation rooms. Query `chat_conversation` with the current user membership and join `conversation:<conversationId>` rooms. Do not query all membership rows and accidentally join rooms named from member ids.',
|
|
141
|
+
'- `chat:message` should broadcast to `conversation:<conversationId>` with `@SOCKET.broadcastToRoom`, then persist through `@REPOS` in the same event script. Do not trigger a flow just to save a chat message unless the product needs workflow semantics.',
|
|
142
|
+
'- For new DMs, do not create an empty conversation just because the user selected a person. Navigate to a draft chat; create the conversation only when the first message is sent. If a DM already exists and is visible to the current user, navigate to it.',
|
|
143
|
+
'- RLS for conversation lists belongs in a route pre-hook that merges into `@QUERY.filter` with `_and`; `@QUERY.filter` already defaults to `{}`. The membership filter must target the conversation membership relation, not a duplicated scalar user id.',
|
|
144
|
+
'- Use cursor pagination for chat history. Initial load should fetch the newest messages and scroll down once; loading older messages must preserve scroll position and must not auto-scroll on new messages while the user is reading older history.',
|
|
145
|
+
'- Typing state should be room-scoped and user-aware. Broadcast typing payloads with `@USER` identity through the conversation room, keep typing active while the input still has text, and clear it only when the input is empty or the socket disconnects.',
|
|
146
|
+
'- If realtime disconnects, disable send controls immediately and show a prominent retry banner. The retry action can reload the page so REST state, socket rooms, and cookies all rehydrate from one known path.',
|
|
147
|
+
'',
|
|
126
148
|
'### `$cache` / `@CACHE` user cache',
|
|
127
149
|
'- `$ctx.$cache` and the `@CACHE` macro use Enfyra-managed **user cache**, not the internal runtime metadata cache.',
|
|
128
150
|
'- Script keys are logical keys such as `user:123` or `report:daily`. Do not include `NODE_NAME`, `user_cache:`, Redis prefixes, or another app namespace in script code.',
|
|
@@ -139,21 +161,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
139
161
|
'- Enfyra exposes OAuth callback at **`{ENFYRA_API_URL}/auth/{provider}/callback`**. Example when `ENFYRA_API_URL` is `http://localhost:3000/api`: **Google** callback URL is **`http://localhost:3000/api/auth/google/callback`** — i.e. `{ENFYRA_API_URL}/auth/google/callback` (same pattern for Facebook/GitHub: `.../auth/facebook/callback`, `.../auth/github/callback`).',
|
|
140
162
|
'- **Google Cloud Console** → OAuth client → **Authorized redirect URIs**: register **exactly** that URL (scheme + host + path, no typo, no extra slash).',
|
|
141
163
|
'- **Enfyra** (`oauth_config_definition` / OAuth settings): field **`redirectUri`** must be the **same string** as in Google Console — byte-for-byte. If they differ, Google or the server will reject the flow.',
|
|
142
|
-
'- **
|
|
164
|
+
'- **Cloud-style proxy mode:** generated Nuxt/Next apps should start OAuth through the same-origin proxy, e.g. `/enfyra/auth/google?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`, and let Enfyra handle the auth response. Do not generate a set-cookie route unless the user explicitly chooses a custom SSR auth boundary.',
|
|
143
165
|
'- **Manual token mode only:** `appCallbackUrl` is the frontend URL where Enfyra redirects after OAuth with `accessToken`, `refreshToken`, etc. in query. Use this only when the app intentionally manages tokens itself; it is not the preferred Nuxt/Next SSR pattern.',
|
|
144
166
|
'',
|
|
145
167
|
'**Server flow (for answering users or designing FE):**',
|
|
146
|
-
'1. **Start login (redirect user in browser):**
|
|
168
|
+
'1. **Start login (redirect user in browser):** Cloud-style apps use `GET /enfyra/auth/{provider}?redirect=<URL_ENCODED_ABSOLUTE_RETURN_URL>&cookieBridgePrefix=/enfyra` from the app origin; direct/manual apps may use `GET {base}/auth/{provider}?redirect=<URL_ENCODED>`. `redirect` is required and is where to send the user after the whole flow.',
|
|
147
169
|
'2. Server **302** to Google/Facebook/GitHub authorization page.',
|
|
148
170
|
'3. Provider calls back: `GET {base}/auth/{provider}/callback?code=...&state=...` (server exchanges code, creates/links user, issues JWT).',
|
|
149
|
-
'4. In
|
|
171
|
+
'4. In Cloud-style proxy mode, Enfyra redirects to `{redirect.origin}{cookieBridgePrefix}/auth/set-cookies?...&redirect=<originalRedirect>`. That request goes through the third app proxy to Enfyra, Enfyra returns `Set-Cookie`, then the browser is redirected to the original `redirect`. In manual token mode, backend redirects to `appCallbackUrl` with token query params. On failure, redirect includes `?error=...`.',
|
|
150
172
|
'',
|
|
151
173
|
'**Frontend build checklist:**',
|
|
152
|
-
'- Nuxt/Next
|
|
153
|
-
'-
|
|
174
|
+
'- Nuxt/Next generated apps: implement a same-origin API proxy such as `/enfyra/**` to the Enfyra API. Browser code never stores JWTs.',
|
|
175
|
+
'- **Password login:** `$fetch("/enfyra/login", { method: "POST", body })`.',
|
|
176
|
+
'- **Fetch user/logout:** `$fetch("/enfyra/me")` and `$fetch("/enfyra/logout", { method: "POST" })`.',
|
|
177
|
+
'- **“Login with Google” button:** `location.href = `/enfyra/auth/google?redirect=${encodeURIComponent(returnUrl)}&cookieBridgePrefix=${encodeURIComponent("/enfyra")}``, where `returnUrl` is absolute, e.g. `${location.origin}/chat`.',
|
|
154
178
|
'- Manual token apps only: register `appCallbackUrl`, read token query params there, strip the URL, store tokens, and use `Authorization: Bearer`.',
|
|
155
179
|
'- **Error handling:** If redirected with `?error=`, show message to user.',
|
|
156
|
-
'- **Do not confuse:** Google’s **Authorized redirect URI** = Enfyra **`redirectUri`** = backend/proxy `{API_BASE}/auth/google/callback`.
|
|
180
|
+
'- **Do not confuse:** Google’s **Authorized redirect URI** = Enfyra **`redirectUri`** = backend/proxy `{API_BASE}/auth/google/callback`. The app return URL is `redirect`, and the third-app proxy prefix is `cookieBridgePrefix`. `appCallbackUrl` is only for manual token mode.',
|
|
157
181
|
'',
|
|
158
182
|
'### System tables — which have REST routes',
|
|
159
183
|
'- **Not all system tables have a REST route.** `query_table`, `find_one_record`, `create_record`, etc. all go through the dynamic REST API and will return **404** if the table has no registered route.',
|
|
@@ -219,7 +243,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
219
243
|
'- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect` are not available (no socket). Use `emitToUser`, `emitToRoom`, `emitToGateway`, `broadcast`.',
|
|
220
244
|
'- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
|
|
221
245
|
'- **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`.',
|
|
222
|
-
'- **Client**: Browser apps must connect through the app/Nuxt Socket.IO bridge, not the hidden Enfyra backend.
|
|
246
|
+
'- **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.',
|
|
223
247
|
'- **Workflow**: Create gateway → `create_record` on `websocket_definition`. Create event → `create_record` on `websocket_event_definition` with `gateway: {id}`. Changes auto-reload; test handlers before saving.',
|
|
224
248
|
'- **Test WS handler (recommended):** `POST {base}/admin/test/run` with `{ kind:"websocket_event", gatewayPath, eventName, timeoutMs, payload, script }` to run a websocket event script without a real client. Returns `{ success, result, logs, emitted }`.',
|
|
225
249
|
'- MCP wrapper: use **`run_admin_test`** with `kind:"websocket_event"` or `kind:"websocket_connection"` instead of hand-building the HTTP call.',
|
package/src/lib/table-tools.js
CHANGED
|
@@ -42,6 +42,16 @@ function parseJsonArrayParam(name, value) {
|
|
|
42
42
|
return parsed;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function normalizeConstraintGroups(name, groups) {
|
|
46
|
+
return groups.map((group, index) => {
|
|
47
|
+
const value = Array.isArray(group) ? group : group?.value;
|
|
48
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
49
|
+
throw new Error(`${name}[${index}] must be a non-empty string array or { "value": [...] }.`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
function normalizeRelationForTablePatch(relation) {
|
|
46
56
|
const {
|
|
47
57
|
sourceTable,
|
|
@@ -102,6 +112,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
102
112
|
'Create a new table definition with an auto-included `id` primary key column.',
|
|
103
113
|
'**Not** for adding a custom API path or handler only — for that use **`create_route`** with an existing `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
|
|
104
114
|
'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
|
|
115
|
+
'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","is_read","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
|
|
105
116
|
'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id or {id}.',
|
|
106
117
|
'Do NOT provide physical FK/junction columns. Never include fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn. Enfyra derives and hides those physical columns from relation propertyName/table metadata.',
|
|
107
118
|
'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
|
|
@@ -119,13 +130,19 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
119
130
|
isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
|
|
120
131
|
columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, defaultValue?, description?, options? }. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"status","type":"enum","options":["draft","published"]}]'),
|
|
121
132
|
relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id or {"id": <id>}. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
|
|
133
|
+
indexes: z.string().optional().describe('JSON array of logical index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Example: [["member","is_read","conversation"],["conversation","member","is_read"]]'),
|
|
134
|
+
uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
|
|
122
135
|
},
|
|
123
|
-
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson }) => {
|
|
136
|
+
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
|
|
124
137
|
const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
|
|
125
138
|
const userColumns = parseJsonArrayParam('columns', columnsJson);
|
|
126
139
|
const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
|
|
140
|
+
const indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
|
|
141
|
+
const uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
|
|
127
142
|
const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
|
|
128
143
|
if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
|
|
144
|
+
if (indexesJson !== undefined) body.indexes = indexes;
|
|
145
|
+
if (uniquesJson !== undefined) body.uniques = uniques;
|
|
129
146
|
const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
|
|
130
147
|
method: 'POST',
|
|
131
148
|
body: JSON.stringify(body),
|
|
@@ -144,8 +161,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
144
161
|
const relHint = userRelations.length
|
|
145
162
|
? `Relation(s) created in same call: ${userRelations.length}.`
|
|
146
163
|
: `No relations were included in this create_table call.`;
|
|
164
|
+
const constraintHint = [
|
|
165
|
+
indexes.length ? `Index group(s): ${indexes.length}.` : null,
|
|
166
|
+
uniques.length ? `Unique group(s): ${uniques.length}.` : null,
|
|
167
|
+
].filter(Boolean).join(' ');
|
|
147
168
|
return {
|
|
148
|
-
content: [{ type: 'text', text: `${colHint}\n${relHint}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
169
|
+
content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
149
170
|
};
|
|
150
171
|
}
|
|
151
172
|
);
|
|
@@ -155,8 +176,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
155
176
|
server.tool(
|
|
156
177
|
'update_table',
|
|
157
178
|
[
|
|
158
|
-
'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled.',
|
|
179
|
+
'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
|
|
159
180
|
'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
|
|
181
|
+
'When passing `indexes` or `uniques`, pass the complete desired array of logical field groups; omitted fields are preserved. Relation property names are allowed and are resolved by Enfyra. Example indexes: [["member","is_read","conversation"],["conversation","member","is_read"]].',
|
|
160
182
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
161
183
|
].join(' '),
|
|
162
184
|
{
|
|
@@ -166,19 +188,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
166
188
|
description: z.string().optional().describe('New description.'),
|
|
167
189
|
isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
|
|
168
190
|
graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing gql_definition.isEnabled. GraphQL still requires Bearer auth.'),
|
|
191
|
+
indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on table_definition.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
|
|
192
|
+
uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on table_definition.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
|
|
169
193
|
},
|
|
170
|
-
async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled }) => {
|
|
194
|
+
async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => {
|
|
171
195
|
const body = {};
|
|
172
196
|
if (name !== undefined) body.name = name;
|
|
173
197
|
if (alias !== undefined) body.alias = alias;
|
|
174
198
|
if (description !== undefined) body.description = description;
|
|
175
199
|
if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
|
|
176
200
|
if (graphqlEnabled !== undefined) body.graphqlEnabled = graphqlEnabled;
|
|
201
|
+
if (indexesJson !== undefined) body.indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
|
|
202
|
+
if (uniquesJson !== undefined) body.uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
|
|
177
203
|
|
|
178
|
-
const result = await
|
|
179
|
-
method: 'PATCH',
|
|
180
|
-
body: JSON.stringify(body),
|
|
181
|
-
});
|
|
204
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
|
|
182
205
|
return {
|
|
183
206
|
content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
184
207
|
};
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -18,6 +18,7 @@ const ENFYRA_PASSWORD = process.env.ENFYRA_PASSWORD || '';
|
|
|
18
18
|
import { login, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
|
|
19
19
|
import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
|
|
20
20
|
import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
|
|
21
|
+
import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
|
|
21
22
|
import { registerTableTools } from './lib/table-tools.js';
|
|
22
23
|
|
|
23
24
|
// Initialize auth module
|
|
@@ -302,6 +303,21 @@ server.tool('get_table_metadata', 'Get metadata for a specific table by name', {
|
|
|
302
303
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
303
304
|
});
|
|
304
305
|
|
|
306
|
+
server.tool(
|
|
307
|
+
'get_enfyra_examples',
|
|
308
|
+
[
|
|
309
|
+
'Return concrete Enfyra examples by category.',
|
|
310
|
+
'Use this before generating schemas, queries, handlers/hooks, SSR app auth, OAuth, Socket.IO, flows, files, or extensions so implementation details follow proven patterns.',
|
|
311
|
+
].join(' '),
|
|
312
|
+
{
|
|
313
|
+
category: z.enum(listExampleCategories().map((item) => item.key)).optional().describe('Example category key. Omit to list categories.'),
|
|
314
|
+
},
|
|
315
|
+
async ({ category }) => {
|
|
316
|
+
const result = getExamples(category);
|
|
317
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
|
|
305
321
|
server.tool(
|
|
306
322
|
'discover_enfyra_system',
|
|
307
323
|
[
|
|
@@ -591,6 +607,8 @@ server.tool(
|
|
|
591
607
|
preHook: {
|
|
592
608
|
runs: 'Before handler.',
|
|
593
609
|
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
|
|
610
|
+
queryContract: '@QUERY.filter is initialized as an object. When adding RLS or tenant filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
|
|
611
|
+
rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
|
|
594
612
|
returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
|
|
595
613
|
},
|
|
596
614
|
handler: {
|