@enfyra/mcp-server 0.0.31 → 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.
|
@@ -11,6 +11,8 @@ Use this skill when designing, reviewing, or debugging Enfyra apps through MCP w
|
|
|
11
11
|
- For RLS hooks, mutate `@QUERY.filter` and preserve existing user filters with `_and`. `@QUERY.filter` is already `{}` when omitted.
|
|
12
12
|
- For dynamic scripts, keep runtime values in their original type. Do not wrap ids or payload values in `String(...)`, `Number(...)`, or `Boolean(...)`.
|
|
13
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.
|
|
14
16
|
|
|
15
17
|
## Debug Workflow
|
|
16
18
|
1. Inspect the table schema, relations, indexes, and route permissions before changing code.
|
|
@@ -19,6 +21,18 @@ Use this skill when designing, reviewing, or debugging Enfyra apps through MCP w
|
|
|
19
21
|
4. Reload metadata/cache after schema or script changes when the tool does not do it automatically.
|
|
20
22
|
5. Retest the exact route/event and compare behavior before/after. Do not invent benchmark numbers.
|
|
21
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
|
+
|
|
22
36
|
## Chat Read/Unread Pattern
|
|
23
37
|
- Use a join table, e.g. `chat_message_read`, with:
|
|
24
38
|
- `message` relation to `chat_message`
|
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
|
|
|
@@ -204,18 +204,21 @@ The Enfyra backend is private infrastructure. MCP, browser code, SSR routes, Gra
|
|
|
204
204
|
When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the Enfyra Cloud pattern:
|
|
205
205
|
|
|
206
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}
|
|
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
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
209
|
- Password login is `POST /enfyra/login`, not `/enfyra/auth/login`.
|
|
210
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
|
|
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`.
|
|
212
213
|
- Use token-query OAuth callback pages only for non-SSR/manual-token apps.
|
|
213
214
|
|
|
214
215
|
---
|
|
215
216
|
|
|
216
217
|
## Tools (summary)
|
|
217
218
|
|
|
218
|
-
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.
|
|
219
222
|
|
|
220
223
|
## Security
|
|
221
224
|
|
package/package.json
CHANGED
|
@@ -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,12 @@ 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
|
-
'- 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}
|
|
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.',
|
|
60
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.',
|
|
61
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.',
|
|
62
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.',
|
|
63
|
-
'- OAuth starts on the same proxy prefix, e.g. **`GET /enfyra/auth/{provider}?redirect=<
|
|
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.',
|
|
64
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.',
|
|
65
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`.',
|
|
66
68
|
'',
|
|
@@ -88,6 +90,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
88
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.',
|
|
89
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`.',
|
|
90
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.',
|
|
91
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.',
|
|
92
97
|
'- **Four REST HTTP operations** on that resource:',
|
|
93
98
|
` - **GET** \`${getList}\` — list / filter (query: filter, sort, page, limit, fields, meta).`,
|
|
@@ -128,6 +133,18 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
128
133
|
'- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
|
|
129
134
|
'- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
|
|
130
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
|
+
'',
|
|
131
148
|
'### `$cache` / `@CACHE` user cache',
|
|
132
149
|
'- `$ctx.$cache` and the `@CACHE` macro use Enfyra-managed **user cache**, not the internal runtime metadata cache.',
|
|
133
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.',
|
|
@@ -144,23 +161,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
144
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`).',
|
|
145
162
|
'- **Google Cloud Console** → OAuth client → **Authorized redirect URIs**: register **exactly** that URL (scheme + host + path, no typo, no extra slash).',
|
|
146
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.',
|
|
147
|
-
'- **Cloud-style proxy mode:** generated Nuxt/Next apps should start OAuth through the same-origin proxy, e.g. `/enfyra/auth/google?redirect
|
|
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.',
|
|
148
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.',
|
|
149
166
|
'',
|
|
150
167
|
'**Server flow (for answering users or designing FE):**',
|
|
151
|
-
'1. **Start login (redirect user in browser):** Cloud-style apps use `GET /enfyra/auth/{provider}?redirect=<
|
|
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.',
|
|
152
169
|
'2. Server **302** to Google/Facebook/GitHub authorization page.',
|
|
153
170
|
'3. Provider calls back: `GET {base}/auth/{provider}/callback?code=...&state=...` (server exchanges code, creates/links user, issues JWT).',
|
|
154
|
-
'4. In Cloud-style proxy mode, Enfyra
|
|
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=...`.',
|
|
155
172
|
'',
|
|
156
173
|
'**Frontend build checklist:**',
|
|
157
174
|
'- Nuxt/Next generated apps: implement a same-origin API proxy such as `/enfyra/**` to the Enfyra API. Browser code never stores JWTs.',
|
|
158
175
|
'- **Password login:** `$fetch("/enfyra/login", { method: "POST", body })`.',
|
|
159
176
|
'- **Fetch user/logout:** `$fetch("/enfyra/me")` and `$fetch("/enfyra/logout", { method: "POST" })`.',
|
|
160
|
-
'- **“Login with Google” button:** `location.href = `/enfyra/auth/google?redirect=${encodeURIComponent(returnUrl)}
|
|
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`.',
|
|
161
178
|
'- Manual token apps only: register `appCallbackUrl`, read token query params there, strip the URL, store tokens, and use `Authorization: Bearer`.',
|
|
162
179
|
'- **Error handling:** If redirected with `?error=`, show message to user.',
|
|
163
|
-
'- **Do not confuse:** Google’s **Authorized redirect URI** = Enfyra **`redirectUri`** = backend/proxy `{API_BASE}/auth/google/callback`. `appCallbackUrl` is only for manual token mode.',
|
|
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.',
|
|
164
181
|
'',
|
|
165
182
|
'### System tables — which have REST routes',
|
|
166
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.',
|
|
@@ -226,7 +243,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
226
243
|
'- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect` are not available (no socket). Use `emitToUser`, `emitToRoom`, `emitToGateway`, `broadcast`.',
|
|
227
244
|
'- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
|
|
228
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`.',
|
|
229
|
-
'- **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.',
|
|
230
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.',
|
|
231
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 }`.',
|
|
232
249
|
'- MCP wrapper: use **`run_admin_test`** with `kind:"websocket_event"` or `kind:"websocket_connection"` instead of hand-building the HTTP call.',
|
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
|
[
|