@enfyra/mcp-server 0.0.31 → 0.0.33

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/index.mjs`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,663 @@
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: "description", type: "text", isNullable: true }
94
+ ])
95
+ })`,
96
+ notes: [
97
+ 'create_table creates the default route for /chat_conversation.',
98
+ 'Keep the latest message as a relation named lastMessage after chat_message exists; do not duplicate last message text/date columns.',
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: "persistStatus", 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: 'Add chat_conversation.lastMessage after chat_message exists',
139
+ code: `update_table({
140
+ tableId: "<chat_conversation_id>",
141
+ relations: JSON.stringify([
142
+ {
143
+ propertyName: "createdBy",
144
+ type: "many-to-one",
145
+ targetTable: { id: "<user_definition_id>" },
146
+ isNullable: true,
147
+ onDelete: "CASCADE"
148
+ },
149
+ {
150
+ propertyName: "lastMessage",
151
+ type: "many-to-one",
152
+ targetTable: { id: "<chat_message_id>" },
153
+ isNullable: true,
154
+ onDelete: "SET NULL"
155
+ }
156
+ ])
157
+ })`,
158
+ notes: [
159
+ 'Use relation fields such as lastMessage.id,lastMessage.text,lastMessage.createdAt when loading conversation lists.',
160
+ 'When deleting the current last message, a post-hook should set lastMessage to the newest remaining message or null.',
161
+ ],
162
+ },
163
+ {
164
+ name: 'Unread/read table with unique and indexes',
165
+ code: `create_table({
166
+ name: "chat_message_read",
167
+ columns: JSON.stringify([
168
+ { name: "isRead", type: "boolean", defaultValue: false },
169
+ { name: "readAt", type: "datetime", isNullable: true }
170
+ ]),
171
+ relations: JSON.stringify([
172
+ { propertyName: "message", type: "many-to-one", targetTable: { id: "<chat_message_id>" }, onDelete: "CASCADE" },
173
+ { propertyName: "conversation", type: "many-to-one", targetTable: { id: "<chat_conversation_id>" }, onDelete: "CASCADE" },
174
+ { propertyName: "member", type: "many-to-one", targetTable: { id: "<user_definition_id>" }, onDelete: "CASCADE" }
175
+ ]),
176
+ uniques: JSON.stringify([["message", "member"]]),
177
+ indexes: JSON.stringify([
178
+ ["member", "isRead", "conversation"],
179
+ ["conversation", "member", "isRead"]
180
+ ])
181
+ })`,
182
+ notes: [
183
+ 'Unread is per user and per message; do not put global read state on conversation.',
184
+ 'For chat-list UX, default to a boolean unread dot instead of exact counts.',
185
+ ],
186
+ },
187
+ ],
188
+ },
189
+ 'queries-deep': {
190
+ title: 'REST queries, filters, meta counts, and deep relation fetches',
191
+ useWhen: 'Use when fetching records, filtering by relations, loading nested data, or counting efficiently.',
192
+ examples: [
193
+ {
194
+ name: 'List current user conversations through RLS',
195
+ code: `GET /enfyra/chat_conversation?fields=id,kind,title,lastMessage.id,lastMessage.text,lastMessage.createdAt&limit=0`,
196
+ notes: [
197
+ 'Use a conversation read pre-hook/RLS boundary so the route only returns conversations visible to @USER.',
198
+ 'lastMessage is a relation to chat_message; do not duplicate preview fields on chat_conversation.',
199
+ 'limit=0 means load all matching conversation rows.',
200
+ 'Do not fetch messages for every conversation on initial list load; load messages after selecting a conversation.',
201
+ ],
202
+ },
203
+ {
204
+ name: 'Fetch one record by id',
205
+ code: `GET /enfyra/post?filter={"id":{"_eq":123}}&limit=1`,
206
+ notes: [
207
+ 'There is no dynamic GET /<table>/<id> route.',
208
+ 'Use filter + limit=1 or MCP find_one_record.',
209
+ ],
210
+ },
211
+ {
212
+ name: 'Count without loading all rows',
213
+ code: `GET /enfyra/chat_message_read?fields=id&limit=1&meta=filterCount&filter={
214
+ "member": { "id": { "_eq": "<currentUserId>" } },
215
+ "isRead": { "_eq": false }
216
+ }`,
217
+ notes: [
218
+ 'Use meta=totalCount with no filter and meta=filterCount with a filter.',
219
+ 'Do not fetch all rows only to count them.',
220
+ ],
221
+ },
222
+ {
223
+ name: 'Deep relation query',
224
+ code: `GET /enfyra/order?fields=id,total,customer&deep={
225
+ "customer": { "fields": "id,email,displayName" },
226
+ "items": {
227
+ "fields": "id,quantity,product",
228
+ "limit": 20,
229
+ "deep": {
230
+ "product": { "fields": "id,name,price" }
231
+ }
232
+ }
233
+ }`,
234
+ notes: [
235
+ 'deep keys must be relation property names.',
236
+ 'Allowed deep options are fields, filter, sort, limit, page, and deep.',
237
+ 'Do not invent deep keys like members unless members is a relation on that table.',
238
+ ],
239
+ },
240
+ ],
241
+ },
242
+ 'handlers-hooks': {
243
+ title: 'Custom handlers, pre-hooks, post-hooks, and script macros',
244
+ useWhen: 'Use when writing Enfyra dynamic JavaScript for REST behavior.',
245
+ examples: [
246
+ {
247
+ name: 'Custom register handler',
248
+ code: `const email = @BODY.email
249
+ const password = @BODY.password
250
+
251
+ if (!email || !password) @THROW400("Email and password are required")
252
+
253
+ const existing = await #user_definition.find({
254
+ filter: { email: { _eq: email } },
255
+ limit: 1
256
+ })
257
+ if (existing.data[0]) @THROW409("Email is already registered")
258
+
259
+ const result = await #user_definition.create({
260
+ data: {
261
+ email,
262
+ password: await @HELPERS.$bcrypt.hash(password)
263
+ }
264
+ })
265
+
266
+ return result.data?.[0] ?? null`,
267
+ notes: [
268
+ 'create/update return { data: [...] }, not a bare row.',
269
+ 'Use @THROW helpers for HTTP errors.',
270
+ 'Prefer macros over raw $ctx when a macro exists.',
271
+ ],
272
+ },
273
+ {
274
+ name: 'Pre-hook RLS filter merge',
275
+ code: `const incoming = @QUERY.filter || {}
276
+ const scope = {
277
+ memberships: {
278
+ member: { id: { _eq: @USER.id } }
279
+ }
280
+ }
281
+
282
+ @QUERY.filter = Object.keys(incoming).length
283
+ ? { _and: [incoming, scope] }
284
+ : scope`,
285
+ notes: [
286
+ '@QUERY.filter is initialized as an object for REST pre-hooks.',
287
+ 'Mutate @QUERY.filter before canonical CRUD runs.',
288
+ ],
289
+ },
290
+ {
291
+ name: 'Post-hook response shaping',
292
+ code: `if (@ERROR) {
293
+ @LOGS("Request failed", @ERROR.message)
294
+ return
295
+ }
296
+
297
+ const row = Array.isArray(@DATA?.data) ? @DATA.data[0] : @DATA
298
+ if (row) {
299
+ row.displayTitle = row.title || row.email || String(row.id)
300
+ }
301
+
302
+ return @DATA`,
303
+ notes: [
304
+ 'Post-hooks run after success and error paths.',
305
+ 'Return non-undefined only when replacing the response body.',
306
+ ],
307
+ },
308
+ ],
309
+ },
310
+ 'permissions-rls': {
311
+ title: 'Route permissions, guards, field permissions, column rules, and RLS',
312
+ useWhen: 'Use when securing routes or shaping what fields a user can read/write.',
313
+ examples: [
314
+ {
315
+ name: 'Publish read-only route',
316
+ code: `update_record({
317
+ tableName: "route_definition",
318
+ id: "<route_id>",
319
+ data: {
320
+ publishedMethods: [{ id: 1 }]
321
+ }
322
+ })`,
323
+ notes: [
324
+ 'Method id 1 is GET. Use method_definition if you need to confirm method ids.',
325
+ 'publishedMethods controls anonymous route access. Route permissions are not for public access.',
326
+ 'Route permissions apply when the method is not public.',
327
+ ],
328
+ },
329
+ {
330
+ name: 'Column rule for email format',
331
+ code: `create_column_rule({
332
+ tableName: "user_definition",
333
+ columnName: "email",
334
+ ruleType: "format",
335
+ ruleConfig: JSON.stringify({ format: "email" }),
336
+ message: "Please enter a valid email address"
337
+ })`,
338
+ notes: [
339
+ 'Column rules validate canonical POST/PATCH body payloads.',
340
+ 'Use column rules before writing custom validation code when the rule is simple.',
341
+ ],
342
+ },
343
+ {
344
+ name: 'Field permission condition',
345
+ code: `create_field_permission({
346
+ tableName: "project",
347
+ fieldName: "internal_notes",
348
+ action: "read",
349
+ condition: JSON.stringify({
350
+ owner: { id: { _eq: "@USER.id" } }
351
+ })
352
+ })`,
353
+ notes: [
354
+ 'Field permissions are for field-level access.',
355
+ 'Use route/pre-hook filters for row-level access.',
356
+ ],
357
+ },
358
+ ],
359
+ },
360
+ websocket: {
361
+ title: 'Socket.IO gateways, events, rooms, and browser connection',
362
+ useWhen: 'Use when creating realtime features.',
363
+ examples: [
364
+ {
365
+ name: 'Browser client connection through app bridge',
366
+ code: `import { io } from "socket.io-client"
367
+
368
+ const socket = io("/chat", {
369
+ path: "/socket.io",
370
+ withCredentials: true,
371
+ transports: ["polling", "websocket"]
372
+ })`,
373
+ notes: [
374
+ '/chat is the Socket.IO namespace.',
375
+ '/socket.io is the app-origin transport path proxied to Enfyra app /ws/socket.io.',
376
+ 'Do not connect browser code directly to the hidden backend.',
377
+ ],
378
+ },
379
+ {
380
+ name: 'Connection script joins user presence room',
381
+ code: `if (!@USER?.id) {
382
+ @SOCKET.disconnect()
383
+ return
384
+ }
385
+
386
+ @SOCKET.join(\`user_\${@USER.id}\`)
387
+ @SOCKET.reply("chat:ready", { userId: @USER.id })`,
388
+ notes: [
389
+ 'Authenticated Enfyra sockets already load @USER.',
390
+ 'Enfyra also joins user_<userId> for emitToUser delivery after connection succeeds.',
391
+ ],
392
+ },
393
+ {
394
+ name: 'Chat join event',
395
+ code: `const conversationId = @BODY.conversationId
396
+ if (!conversationId) @THROW400("conversationId is required")
397
+
398
+ const membership = await @REPOS.chat_conversation_member.find({
399
+ filter: {
400
+ conversation: { id: { _eq: conversationId } },
401
+ member: { id: { _eq: @USER.id } }
402
+ },
403
+ limit: 1
404
+ })
405
+
406
+ if (!membership.data[0]) @THROW403("Not a conversation member")
407
+
408
+ @SOCKET.join(\`conversation:\${conversationId}\`)
409
+ @SOCKET.reply("chat:joined", { conversationId })`,
410
+ notes: [
411
+ 'Join conversation rooms, not member-id rooms.',
412
+ 'Check membership server-side; do not trust the client.',
413
+ ],
414
+ },
415
+ {
416
+ name: 'Chat message event with room broadcast and persistence',
417
+ code: `const { conversationId, text, clientId } = @BODY
418
+ if (!conversationId || !text) @THROW400("conversationId and text are required")
419
+
420
+ const membership = await @REPOS.chat_conversation_member.find({
421
+ filter: {
422
+ conversation: { id: { _eq: conversationId } },
423
+ member: { id: { _eq: @USER.id } }
424
+ },
425
+ limit: 1
426
+ })
427
+ if (!membership.data[0]) @THROW403("Not a conversation member")
428
+
429
+ const created = await @REPOS.chat_message.create({
430
+ data: {
431
+ conversation: { id: conversationId },
432
+ sender: { id: @USER.id },
433
+ text,
434
+ persistStatus: "persisted"
435
+ }
436
+ })
437
+
438
+ const message = created.data?.[0] ?? null
439
+ if (message?.id) {
440
+ await @REPOS.chat_conversation.update({
441
+ id: conversationId,
442
+ data: { lastMessage: { id: message.id }, updatedAt: message.createdAt || new Date().toISOString() }
443
+ })
444
+ }
445
+ @SOCKET.emitToRoom(\`conversation:\${conversationId}\`, "chat:message", {
446
+ clientId,
447
+ message
448
+ })
449
+
450
+ return { ok: true, message }`,
451
+ notes: [
452
+ 'Do not ask the client for senderId; use @USER.id.',
453
+ 'Event scripts should explicitly emit replies/broadcasts.',
454
+ ],
455
+ },
456
+ ],
457
+ },
458
+ flows: {
459
+ title: 'Flows and step scripts',
460
+ useWhen: 'Use when automating background work or chaining steps.',
461
+ examples: [
462
+ {
463
+ name: 'Manual flow trigger from a post-hook',
464
+ code: `if (!@ERROR && @DATA?.data?.[0]) {
465
+ await @TRIGGER("send-welcome-email", {
466
+ userId: @DATA.data[0].id,
467
+ email: @DATA.data[0].email
468
+ })
469
+ }`,
470
+ notes: [
471
+ 'Use flows for workflow semantics, retries, and history.',
472
+ 'Do not use a flow just to persist a normal chat message.',
473
+ ],
474
+ },
475
+ {
476
+ name: 'Flow condition step',
477
+ code: `const order = @FLOW_PAYLOAD.order
478
+ return order && order.total > 1000`,
479
+ notes: [
480
+ 'Condition steps use JavaScript truthy/falsy.',
481
+ 'Children run according to branch true/false.',
482
+ ],
483
+ },
484
+ {
485
+ name: 'Flow query step config',
486
+ code: `{
487
+ "table": "user_definition",
488
+ "filter": { "email": { "_contains": "@example.com" } },
489
+ "limit": 50
490
+ }`,
491
+ notes: [
492
+ 'Step configs are JSON; script steps use code strings.',
493
+ 'Use public-safe URLs for HTTP steps.',
494
+ ],
495
+ },
496
+ ],
497
+ },
498
+ files: {
499
+ title: 'Files, folders, upload metadata, and assets',
500
+ useWhen: 'Use when handling uploads or returning uploaded files.',
501
+ examples: [
502
+ {
503
+ name: 'Upload a file from browser',
504
+ code: `const form = new FormData()
505
+ form.append("file", file)
506
+ form.append("folder", folderId)
507
+ form.append("title", "Invoice")
508
+
509
+ const uploaded = await fetch("/enfyra/files/upload", {
510
+ method: "POST",
511
+ credentials: "include",
512
+ body: form
513
+ }).then((res) => res.json())`,
514
+ notes: [
515
+ 'Do not set Content-Type manually for FormData.',
516
+ 'Use file routes/helpers instead of writing binary data into normal tables.',
517
+ ],
518
+ },
519
+ {
520
+ name: 'Use uploaded file in handler',
521
+ code: `const file = $ctx.$uploadedFile
522
+ if (!file) @THROW400("File is required")
523
+
524
+ return {
525
+ filename: file.originalname,
526
+ mimetype: file.mimetype,
527
+ size: file.size
528
+ }`,
529
+ notes: [
530
+ 'Use file-specific context only in upload-capable routes.',
531
+ ],
532
+ },
533
+ ],
534
+ },
535
+ extensions: {
536
+ title: 'Dynamic app extensions and menus',
537
+ useWhen: 'Use when adding custom UI pages to the Enfyra app.',
538
+ examples: [
539
+ {
540
+ name: 'Create menu then extension',
541
+ code: `create_menu({
542
+ label: "Reports",
543
+ type: "Menu",
544
+ path: "/reports",
545
+ icon: "lucide:bar-chart-3",
546
+ order: 20,
547
+ isEnabled: true
548
+ })
549
+
550
+ // Read the created menu id from the tool response, then:
551
+ create_extension({
552
+ type: "page",
553
+ name: "ReportsPage",
554
+ description: "Reports dashboard",
555
+ menuId: "<created-menu-id>",
556
+ code: "<template><section class=\\"min-h-full w-full p-4 sm:p-6 lg:p-8\\">Reports</section></template><script setup>useHeaderActionRegistry({ id: 'refresh-reports', label: 'Refresh', icon: 'lucide:refresh-cw', onClick: () => {}, order: 0 })</script>",
557
+ isEnabled: true
558
+ })`,
559
+ notes: [
560
+ 'Menu provides navigation; extension provides content.',
561
+ 'Use menu_definition.label, not title.',
562
+ 'For page extensions, create the menu first and pass menuId to create_extension.',
563
+ 'Page extensions should be full-bleed by default and responsive from the first version.',
564
+ ],
565
+ },
566
+ {
567
+ name: 'Plan a Cloud admin dashboard as multiple pages',
568
+ code: `// Recommended menu shape for an operations surface:
569
+ create_menu({
570
+ type: "Dropdown Menu",
571
+ label: "Cloud",
572
+ path: "/cloud",
573
+ icon: "lucide:cloud",
574
+ order: 2,
575
+ isEnabled: true
576
+ })
577
+
578
+ // Child page extensions should be focused:
579
+ // /dashboard overview with time range KPIs
580
+ // /cloud/projects project status and drill-downs
581
+ // /cloud/provisioning provisioning timeline/failures/slow steps
582
+ // /cloud/billing orders/subscriptions/refunds
583
+ // /cloud/infrastructure hosts/capacity/plans/system credential readiness
584
+ // /cloud/readiness legal/Paddle/landing launch checklist
585
+ // Use UTabs inside large pages instead of placing every section in one dashboard.`,
586
+ notes: [
587
+ 'Design the menu/page split before generating dashboard code.',
588
+ 'Keep /dashboard as overview and use focused pages for operational domains.',
589
+ 'UTabs is available in eApp extension runtime for page-level sections.',
590
+ ],
591
+ },
592
+ {
593
+ name: 'Extension fetches Enfyra data',
594
+ code: `<script setup>
595
+ const { data, pending, execute: fetchOrders } = useApi('/order_definition', {
596
+ query: {
597
+ limit: 10,
598
+ sort: '-createdAt'
599
+ }
600
+ })
601
+
602
+ onMounted(() => fetchOrders())
603
+ </script>
604
+
605
+ <template>
606
+ <UButton :loading="pending" @click="fetchOrders">Refresh</UButton>
607
+ <pre>{{ data }}</pre>
608
+ </template>`,
609
+ notes: [
610
+ 'Use app-provided composables in extensions.',
611
+ 'useApi does not auto-run; call execute() on mounted or through an action.',
612
+ 'Keep extension UI focused; move backend logic into handlers/hooks when needed.',
613
+ ],
614
+ },
615
+ {
616
+ name: 'Extension can use modern browser APIs',
617
+ code: `<script setup lang="ts">
618
+ const statuses = ['active', 'ready']
619
+ const ok = statuses.includes('active')
620
+ const requiredTerms = new Set(['cloud-terms', 'privacy-policy', 'refund-policy'])
621
+ const loaded = await Promise.all([Promise.resolve(1), Promise.resolve(2)])
622
+ const label = String('pending_payment').replace(/_/g, ' ')
623
+ const date = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())
624
+ console.log(ok, requiredTerms.has('cloud-terms'), loaded, label, date)
625
+ </script>`,
626
+ notes: [
627
+ 'Do not rewrite extension code to ES5 when tooling rejects modern APIs.',
628
+ 'If diagnostics complain about these APIs, fix eApp extension TypeScript lib/runtime contract.',
629
+ ],
630
+ },
631
+ ],
632
+ },
633
+ };
634
+
635
+ export function listExampleCategories() {
636
+ return Object.entries(EXAMPLE_CATEGORIES).map(([key, value]) => ({
637
+ key,
638
+ title: value.title,
639
+ useWhen: value.useWhen,
640
+ }));
641
+ }
642
+
643
+ export function getExamples(category) {
644
+ if (!category) {
645
+ return {
646
+ categories: listExampleCategories(),
647
+ hint: 'Call get_enfyra_examples with one category key to retrieve concrete examples for that area.',
648
+ };
649
+ }
650
+
651
+ const entry = EXAMPLE_CATEGORIES[category];
652
+ if (!entry) {
653
+ return {
654
+ error: `Unknown example category "${category}"`,
655
+ categories: listExampleCategories(),
656
+ };
657
+ }
658
+
659
+ return {
660
+ category,
661
+ ...entry,
662
+ };
663
+ }
@@ -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}/**` } } }`. Browser/generated app code calls `/enfyra/...`, not the raw Enfyra backend 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=<returnUrl>`**. Use token-query callback handling only when the app intentionally manages tokens itself.',
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
  '',
@@ -76,7 +78,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
76
78
  '### After a new table is created',
77
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.',
78
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.',
79
- '- 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.',
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","isRead","conversation"],["conversation","member","isRead"]]` and `uniques: [["message","member"]]`. Relation property names are allowed; Enfyra resolves them to physical FK columns for SQL and Mongo.',
80
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.',
81
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.',
82
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`.',
@@ -87,7 +89,11 @@ export function buildMcpServerInstructions(apiBaseUrl) {
87
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.',
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
- '- **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.',
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 `isRead`, nullable `readAt`, unique `["message","member"]`, and indexes `["member","isRead","conversation"]` plus `["conversation","member","isRead"]`. The UI can render a dot by checking existence of unread rows instead of counting every unread message.',
93
+ '- **Realtime/chat latest message modeling:** keep `chat_conversation.lastMessage` as a nullable many-to-one relation to `chat_message`. Do not duplicate latest message text/date onto `chat_conversation`. Load conversation lists with relation fields such as `lastMessage.id,lastMessage.text,lastMessage.createdAt`, update `lastMessage` after the message is persisted, and repair it in a `DELETE /chat_message` post-hook when deleting the current latest message.',
94
+ '- **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.',
95
+ '- **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.',
96
+ '- **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
97
  '- 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
98
  '- **Four REST HTTP operations** on that resource:',
93
99
  ` - **GET** \`${getList}\` — list / filter (query: filter, sort, page, limit, fields, meta).`,
@@ -128,6 +134,18 @@ export function buildMcpServerInstructions(apiBaseUrl) {
128
134
  '- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
129
135
  '- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
130
136
  '',
137
+ '### Chat / realtime app rules learned from implementation review',
138
+ '- 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.',
139
+ '- 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.',
140
+ '- 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.',
141
+ '- 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.',
142
+ '- `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.',
143
+ '- 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.',
144
+ '- 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.',
145
+ '- 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.',
146
+ '- 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.',
147
+ '- 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.',
148
+ '',
131
149
  '### `$cache` / `@CACHE` user cache',
132
150
  '- `$ctx.$cache` and the `@CACHE` macro use Enfyra-managed **user cache**, not the internal runtime metadata cache.',
133
151
  '- 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 +162,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
144
162
  '- 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
163
  '- **Google Cloud Console** → OAuth client → **Authorized redirect URIs**: register **exactly** that URL (scheme + host + path, no typo, no extra slash).',
146
164
  '- **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=...`, and let Enfyra handle the auth response. Do not generate a set-cookie route unless the user explicitly chooses a custom SSR auth boundary.',
165
+ '- **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
166
  '- **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
167
  '',
150
168
  '**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=<URL_ENCODED>` 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.',
169
+ '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
170
  '2. Server **302** to Google/Facebook/GitHub authorization page.',
153
171
  '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 owns the auth response behind the app proxy. In manual token mode, backend redirects to `appCallbackUrl` with token query params. On failure, redirect includes `?error=...`.',
172
+ '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
173
  '',
156
174
  '**Frontend build checklist:**',
157
175
  '- Nuxt/Next generated apps: implement a same-origin API proxy such as `/enfyra/**` to the Enfyra API. Browser code never stores JWTs.',
158
176
  '- **Password login:** `$fetch("/enfyra/login", { method: "POST", body })`.',
159
177
  '- **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)}``.',
178
+ '- **“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
179
  '- Manual token apps only: register `appCallbackUrl`, read token query params there, strip the URL, store tokens, and use `Authorization: Bearer`.',
162
180
  '- **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.',
181
+ '- **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
182
  '',
165
183
  '### System tables — which have REST routes',
166
184
  '- **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 +244,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
226
244
  '- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect` are not available (no socket). Use `emitToUser`, `emitToRoom`, `emitToGateway`, `broadcast`.',
227
245
  '- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
228
246
  '- **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. For the Enfyra Nuxt bridge this is typically `io("/ws/<namespace>")`, e.g. `io("/ws/chat")`, while the backend gateway metadata path remains `/chat`.',
247
+ '- **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
248
  '- **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
249
  '- **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
250
  '- MCP wrapper: use **`run_admin_test`** with `kind:"websocket_event"` or `kind:"websocket_connection"` instead of hand-building the HTTP call.',
@@ -253,6 +271,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
253
271
  '- **CRITICAL:** MUST call `create_record` or `update_record` on `extension_definition` — outputting Vue code in chat does NOT save it. User will NOT see it.',
254
272
  '- **Code format:** Vue SFC only. Structure: `<template>...</template>` + `<script setup>...</script>`. Server auto-compiles; if compile fails, fix and retry.',
255
273
  '- **NO import statements.** All APIs are injected globally (see full list below).',
274
+ '- **Design first for dashboards:** before creating/updating a dashboard extension, define the menu/page split, time range controls, tabs, and drill-down links. Keep `/dashboard` as overview; create focused menu pages for projects, provisioning, billing, infrastructure, and readiness when the surface grows.',
275
+ '- **Page layout default:** page extensions should render full-bleed inside the app shell by default. Do not wrap the entire page in a centered card/container unless explicitly requested. Use responsive grids/stacks from the first version so the page works on desktop, tablet, and mobile.',
276
+ '- **Do not downgrade extension code to ES5 to appease tooling.** eApp extension runtime should support normal browser/runtime APIs such as `Array.includes`, `Set`, `Promise.all`, `String.replace`, `Intl.DateTimeFormat`, and `Intl.NumberFormat`. If diagnostics reject these, fix eApp extension checker/runtime contract instead of rewriting generated extension code around the limitation.',
256
277
  '',
257
278
  '#### Injected Vue API functions:',
258
279
  '- Reactivity: `ref`, `reactive`, `computed`, `readonly`, `shallowRef`, `shallowReactive`',
@@ -294,6 +315,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
294
315
  '- **File Manager:** `FileManager`, `FileView`, `FileGridCard`, `CreateFolderModal`',
295
316
  '- **Menu:** `MenuRenderer`, `MenuItemEditor`',
296
317
  '- **UI:** `UButton`, `UCard`, `UInput`, `UTable`, `UBadge` (if available)',
318
+ '- **Tabs:** `UTabs` is available in current eApp extension runtime. Use it for page-level sections when a page would otherwise become too long.',
297
319
  '- **Extension:** `Widget` — embed widget extension via `<Widget :id="extensionId" />`',
298
320
  '- **WebSocket:** `WebSocketManager`',
299
321
  '- **Permission:** `PermissionGate`, `PermissionManager`',
@@ -306,6 +328,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
306
328
  '- **FormEditor field-map:** Customize fields via `:field-map`. Options: `label`, `description`, `hideLabel`, `hideDescription`, `component`, `componentProps`, `type`, `disabled`, `placeholder`, `permission`, `excludedOptions`/`includedOptions`, `fieldProps` (e.g. grid `class: \'md:col-span-2\'` when `layout=\'grid\'`), `booleanWrapperClass`, `fieldWrapperClass`. Optional `:sections` — array of `{ id, title?, hideHeading?, headingClass?, class?, rootClass?, fields: string[] }`; field order follows `fields`; unlisted columns render after. Custom input component: `modelValue` / `update:modelValue`.',
307
329
  '- **type "page":** Full-page extension. Requires `menu: { id }` — create menu first (`create_menu` or `create_record` on `menu_definition`), find by path/label, then create extension with `menu: { id: menuId }`. `menu_definition` uses **label** not name — filter by `label` or `path`.',
308
330
  '- **type "widget":** Widget extension. No menu required. Embed via `<Widget :id="extensionId" />` in other extensions or pages.',
331
+ '- **Existing pages:** if a menu already has a page extension, update that `extension_definition` record instead of creating a duplicate menu/extension. For example `/dashboard` is menu-driven and may already have an extension attached.',
309
332
  '',
310
333
  '#### NPM packages (install via MCP):',
311
334
  '- **Use `install_package` tool** — just pass the package name and type. The tool auto-fetches version from NPM, checks if already installed, and creates the record.',
@@ -112,7 +112,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
112
112
  'Create a new table definition with an auto-included `id` primary key column.',
113
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).',
114
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.',
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","isRead","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
116
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}.',
117
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.',
118
118
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
@@ -130,7 +130,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
130
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.'),
131
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"]}]'),
132
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"]]'),
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","isRead","conversation"],["conversation","member","isRead"]]'),
134
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"]]'),
135
135
  },
136
136
  async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
@@ -178,7 +178,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
178
178
  [
179
179
  'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
180
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"]].',
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","isRead","conversation"],["conversation","member","isRead"]].',
182
182
  'Run schema changes sequentially — migration locks DB per operation.',
183
183
  ].join(' '),
184
184
  {
@@ -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
  [