@enfyra/mcp-server 0.0.29 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex/skills/enfyra-mcp-performance/SKILL.md +33 -0
- package/README.md +15 -11
- package/package.json +3 -2
- package/src/lib/mcp-instructions.js +25 -17
- package/src/lib/table-tools.js +31 -8
- package/src/mcp-server-entry.mjs +2 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Enfyra MCP Performance And Debug Mode
|
|
2
|
+
|
|
3
|
+
Use this skill when designing, reviewing, or debugging Enfyra apps through MCP where performance, read/write shape, indexes, websocket latency, RLS filters, or query correctness matter.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
- Verify capability with live metadata before claiming performance behavior. Use `inspect_table`, `inspect_route`, `discover_query_capabilities`, and `discover_runtime_context` first.
|
|
7
|
+
- Prefer indexed relation filters over scalar mirror columns. Relation property names can be used in `indexes` and `uniques`; Enfyra resolves them to physical FK columns.
|
|
8
|
+
- For hot read paths, design indexes with the most selective/user-scoped field first. Examples: `["member","is_read","conversation"]` for unread lookup and `["conversation","member","is_read"]` for mark-read updates.
|
|
9
|
+
- Use existence checks for UI dots and badges unless the user explicitly needs exact counts. Avoid count queries on every conversation row.
|
|
10
|
+
- Use `meta=filterCount` or MCP `count_records` only when count is the product requirement.
|
|
11
|
+
- For RLS hooks, mutate `@QUERY.filter` and preserve existing user filters with `_and`. `@QUERY.filter` is already `{}` when omitted.
|
|
12
|
+
- For dynamic scripts, keep runtime values in their original type. Do not wrap ids or payload values in `String(...)`, `Number(...)`, or `Boolean(...)`.
|
|
13
|
+
- For websocket apps, connect browsers through the Enfyra app/Nuxt bridge, not the hidden backend. Event handlers should be script-owned and use `@SOCKET` explicitly.
|
|
14
|
+
|
|
15
|
+
## Debug Workflow
|
|
16
|
+
1. Inspect the table schema, relations, indexes, and route permissions before changing code.
|
|
17
|
+
2. Reproduce with the smallest real request or Socket.IO event. Prefer `test_rest_endpoint` or `run_admin_test` when available.
|
|
18
|
+
3. If the problem is performance, state the exact query shape and expected index. Add or update `indexes` on `table_definition` through `create_table` or `update_table`.
|
|
19
|
+
4. Reload metadata/cache after schema or script changes when the tool does not do it automatically.
|
|
20
|
+
5. Retest the exact route/event and compare behavior before/after. Do not invent benchmark numbers.
|
|
21
|
+
|
|
22
|
+
## Chat Read/Unread Pattern
|
|
23
|
+
- Use a join table, e.g. `chat_message_read`, with:
|
|
24
|
+
- `message` relation to `chat_message`
|
|
25
|
+
- `conversation` relation to `chat_conversation`
|
|
26
|
+
- `member` relation to `user_definition`
|
|
27
|
+
- `is_read` boolean
|
|
28
|
+
- `read_at` date nullable
|
|
29
|
+
- Add unique `["message","member"]`.
|
|
30
|
+
- Add indexes `["member","is_read","conversation"]` and `["conversation","member","is_read"]`.
|
|
31
|
+
- On message create, create read rows for conversation members. Sender rows start as read; other member rows start unread.
|
|
32
|
+
- On conversation open/read, update unread rows for `@USER` in that conversation to read.
|
|
33
|
+
- UI unread dots should check whether an unread row exists; count only when requested.
|
package/README.md
CHANGED
|
@@ -185,26 +185,30 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
|
|
|
185
185
|
|
|
186
186
|
| Variable | Description | Default |
|
|
187
187
|
|----------|-------------|---------|
|
|
188
|
-
| `ENFYRA_API_URL` | Base for REST + GraphQL + auth
|
|
188
|
+
| `ENFYRA_API_URL` | Base for REST + GraphQL + auth through the Nuxt/app proxy | `http://localhost:3000/api` |
|
|
189
189
|
| `ENFYRA_EMAIL` | Admin email | — |
|
|
190
190
|
| `ENFYRA_PASSWORD` | Admin password | — |
|
|
191
191
|
|
|
192
|
-
### `ENFYRA_API_URL` —
|
|
192
|
+
### `ENFYRA_API_URL` — use the app proxy
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|------|---------|-------------|
|
|
196
|
-
| **Via Nuxt admin (typical local dev)** | `http://localhost:3000/api` | Browser app on 3000; Nitro proxies `/api/*` to Nest (`API_URL`, often `http://localhost:1105`). GraphQL at `{ENFYRA_API_URL}/graphql` is proxied to the backend `/graphql`. |
|
|
197
|
-
| **Direct to Nest backend** | `http://localhost:1105` | Call Enfyra **without** the Nuxt prefix. **Do not** append `/api` unless your reverse proxy serves routes under `/api`—`http://localhost:1105/api/...` will not match default Nest paths. |
|
|
194
|
+
For normal apps and demos, set `ENFYRA_API_URL` to the Nuxt/app proxy:
|
|
198
195
|
|
|
199
|
-
|
|
196
|
+
```text
|
|
197
|
+
http://localhost:3000/api
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The Enfyra backend is private infrastructure. MCP, browser code, SSR routes, GraphQL calls, and generated app code should go through the app origin `/api/**`; do not connect them directly to the backend host/port. Direct backend URLs are only for Enfyra core/server debugging when you intentionally bypass the app proxy.
|
|
200
201
|
|
|
201
202
|
### SSR app auth pattern
|
|
202
203
|
|
|
203
|
-
When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra,
|
|
204
|
+
When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the Enfyra Cloud pattern:
|
|
204
205
|
|
|
205
|
-
- Browser code calls `{{ appOrigin }}/
|
|
206
|
-
-
|
|
207
|
-
-
|
|
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}/**` } } }`.
|
|
208
|
+
- Generated apps should not create custom login/logout/me routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when the proxy is enough.
|
|
209
|
+
- Password login is `POST /enfyra/login`, not `/enfyra/auth/login`.
|
|
210
|
+
- Fetch the current user with `GET /enfyra/me` and logout with `POST /enfyra/logout`.
|
|
211
|
+
- OAuth starts through the same proxy prefix, for example `/enfyra/auth/google?redirect=...`.
|
|
208
212
|
- Use token-query OAuth callback pages only for non-SSR/manual-token apps.
|
|
209
213
|
|
|
210
214
|
---
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enfyra/mcp-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.31",
|
|
4
4
|
"description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "src/index.mjs",
|
|
8
8
|
"bin": "src/index.mjs",
|
|
9
9
|
"files": [
|
|
10
|
-
"src"
|
|
10
|
+
"src",
|
|
11
|
+
".codex/skills"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|
|
13
14
|
"start": "node src/index.mjs",
|
|
@@ -48,19 +48,21 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
48
48
|
'- **Extensions/packages/menus:** `extension_definition`, `menu_definition`, `package_definition`, `bootstrap_script_definition`; extensions are Vue SFC only, and packages should be installed with `install_package`.',
|
|
49
49
|
'- **Platform config:** `setting_definition`, `cors_origin_definition`, reload endpoints, logs, and metadata endpoints.',
|
|
50
50
|
'',
|
|
51
|
-
'### ENFYRA_API_URL (
|
|
52
|
-
'- **
|
|
53
|
-
'-
|
|
51
|
+
'### ENFYRA_API_URL (MCP must use the app proxy)',
|
|
52
|
+
'- **Required default:** point MCP at the Nuxt/app origin proxy, e.g. `http://localhost:3000/api`. Nuxt proxies `/api/*` to the hidden Enfyra backend (`API_URL`, e.g. `http://localhost:1105`).',
|
|
53
|
+
'- Treat the Enfyra backend as private infrastructure. Do not tell app-building agents to connect MCP, browser code, SSR code, or generated app code directly to the backend host/port.',
|
|
54
|
+
'- Direct-to-backend URLs such as `http://localhost:1105` are only for Enfyra core/server debugging when the user explicitly asks to bypass the app proxy. They are not valid guidance for normal apps or deployable demos.',
|
|
54
55
|
'- GraphQL: `{base}/graphql` and `{base}/graphql-schema` always share this same base.',
|
|
55
56
|
'',
|
|
56
57
|
'### When the user asks how to connect a Nuxt/Next/SSR app to Enfyra',
|
|
57
58
|
'- This is guidance for the assistant to answer users and generate app code. It is not a separate MCP tool workflow.',
|
|
58
|
-
'-
|
|
59
|
-
'-
|
|
60
|
-
'-
|
|
61
|
-
'-
|
|
62
|
-
'-
|
|
63
|
-
'- If
|
|
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
|
+
'- 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
|
+
'- 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
|
+
'- 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
|
+
'- 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
|
+
'- 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`.',
|
|
64
66
|
'',
|
|
65
67
|
'### Routes vs tables (custom endpoints, handlers, hooks)',
|
|
66
68
|
'- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
|
|
@@ -74,15 +76,18 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
74
76
|
'### After a new table is created',
|
|
75
77
|
'- 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.',
|
|
76
78
|
'- 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.',
|
|
77
80
|
'- 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.',
|
|
78
81
|
'- 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.',
|
|
79
82
|
'- **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`.',
|
|
80
83
|
'- When modeling features that involve users, relate domain tables directly to `user_definition` through real Enfyra relations. Examples: `chat_conversation_member.member` → `user_definition`, `chat_message.sender` → `user_definition`, `order.customer` → `user_definition`. Do not create duplicate scalar columns like `userId` or separate profile records just to point back to a user.',
|
|
84
|
+
'- **Do not create reverse relations on `user_definition` by default.** For domain records that point to users, create only the owning relation on the domain table, e.g. `chat_message.sender -> user_definition`, and omit `inversePropertyName` unless the user explicitly asks for a reverse user field. Reverse fields like `user_definition.chatMessages`, `user_definition.orders`, or `user_definition.memberships` bloat user metadata and make `fields=*` user queries heavy.',
|
|
81
85
|
'- **Prefer real relations over relation-shaped columns.** If a field represents another record or list of records, model it as `relations`, not as columns such as `userId`, `author_id`, `categoryIds`, `teamIds`, `itemsJson`, or object/array JSON containing related records. Ask only when the user explicitly wants denormalized snapshot data.',
|
|
82
86
|
'- Common mapping: one owner record → `many-to-one`; one record has many children → define the child `many-to-one` and use inverse/read deep relation; peer/tag lists → `many-to-many`; one profile/settings row per parent → `one-to-one` when supported by the model.',
|
|
83
87
|
'- 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.',
|
|
84
88
|
'- **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.',
|
|
85
89
|
'- 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.',
|
|
86
91
|
'- 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.',
|
|
87
92
|
'- **Four REST HTTP operations** on that resource:',
|
|
88
93
|
` - **GET** \`${getList}\` — list / filter (query: filter, sort, page, limit, fields, meta).`,
|
|
@@ -118,6 +123,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
118
123
|
'',
|
|
119
124
|
'### Dynamic script syntax preference',
|
|
120
125
|
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.',
|
|
126
|
+
'- Do not coerce dynamic script values with `String(...)`, `Number(...)`, or `Boolean(...)`. Enfyra payloads, user ids, record ids, and relation ids should keep their runtime type; validate required values and pass them through directly.',
|
|
121
127
|
'- Use raw `$ctx` only when there is no template macro for the field or helper you need.',
|
|
122
128
|
'- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
|
|
123
129
|
'- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
|
|
@@ -138,21 +144,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
138
144
|
'- 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`).',
|
|
139
145
|
'- **Google Cloud Console** → OAuth client → **Authorized redirect URIs**: register **exactly** that URL (scheme + host + path, no typo, no extra slash).',
|
|
140
146
|
'- **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.',
|
|
141
|
-
'- **
|
|
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.',
|
|
142
148
|
'- **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.',
|
|
143
149
|
'',
|
|
144
150
|
'**Server flow (for answering users or designing FE):**',
|
|
145
|
-
'1. **Start login (redirect user in browser):**
|
|
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.',
|
|
146
152
|
'2. Server **302** to Google/Facebook/GitHub authorization page.',
|
|
147
153
|
'3. Provider calls back: `GET {base}/auth/{provider}/callback?code=...&state=...` (server exchanges code, creates/links user, issues JWT).',
|
|
148
|
-
'4. In
|
|
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=...`.',
|
|
149
155
|
'',
|
|
150
156
|
'**Frontend build checklist:**',
|
|
151
|
-
'- Nuxt/Next
|
|
152
|
-
'-
|
|
157
|
+
'- Nuxt/Next generated apps: implement a same-origin API proxy such as `/enfyra/**` to the Enfyra API. Browser code never stores JWTs.',
|
|
158
|
+
'- **Password login:** `$fetch("/enfyra/login", { method: "POST", body })`.',
|
|
159
|
+
'- **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)}``.',
|
|
153
161
|
'- Manual token apps only: register `appCallbackUrl`, read token query params there, strip the URL, store tokens, and use `Authorization: Bearer`.',
|
|
154
162
|
'- **Error handling:** If redirected with `?error=`, show message to user.',
|
|
155
|
-
'- **Do not confuse:** Google’s **Authorized redirect URI** = Enfyra **`redirectUri`** = backend/proxy `{API_BASE}/auth/google/callback`.
|
|
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.',
|
|
156
164
|
'',
|
|
157
165
|
'### System tables — which have REST routes',
|
|
158
166
|
'- **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.',
|
|
@@ -194,7 +202,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
194
202
|
'- In custom dynamic code, use the same lightweight pattern: `const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); const count = filter ? result.meta?.filterCount : result.meta?.totalCount;`.',
|
|
195
203
|
'',
|
|
196
204
|
'### GraphQL (same prefix as REST / ENFYRA_API_URL)',
|
|
197
|
-
`- **POST** \`${graphqlHttpUrl}\` — GraphQL endpoint (body: GraphQL query). With
|
|
205
|
+
`- **POST** \`${graphqlHttpUrl}\` — GraphQL endpoint (body: GraphQL query). With the required app proxy base: e.g. \`http://localhost:3000/api/graphql\`.`,
|
|
198
206
|
`- **GET** \`${graphqlSchemaUrl}\` — current schema SDL (text); same base pattern as above.`,
|
|
199
207
|
'- A table appears in the schema when `gql_definition` has an enabled row for that table. The REST route `availableMethods` list does not enable GraphQL.',
|
|
200
208
|
'- **Query** field = same string as `table_definition.name`. **Mutations** are literal concat: `create_`+tableName, `update_`+tableName, `delete_`+tableName (e.g. tableName `post` → `create_post`, input type `postInput`). See `generate-type-defs.ts`. If every column is skipped for input (only PK, or only `createdAt`/`updatedAt`, or all unpublished), the schema emits **no** `Query.<tableName>` and **no** create/update/delete mutations for that table (an output `type` may still exist for relation wiring).',
|
|
@@ -218,7 +226,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
218
226
|
'- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect` are not available (no socket). Use `emitToUser`, `emitToRoom`, `emitToGateway`, `broadcast`.',
|
|
219
227
|
'- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
|
|
220
228
|
'- **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`.',
|
|
221
|
-
'- **Client**:
|
|
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`.',
|
|
222
230
|
'- **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.',
|
|
223
231
|
'- **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 }`.',
|
|
224
232
|
'- MCP wrapper: use **`run_admin_test`** with `kind:"websocket_event"` or `kind:"websocket_connection"` instead of hand-building the HTTP call.',
|
package/src/lib/table-tools.js
CHANGED
|
@@ -42,6 +42,16 @@ function parseJsonArrayParam(name, value) {
|
|
|
42
42
|
return parsed;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function normalizeConstraintGroups(name, groups) {
|
|
46
|
+
return groups.map((group, index) => {
|
|
47
|
+
const value = Array.isArray(group) ? group : group?.value;
|
|
48
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
49
|
+
throw new Error(`${name}[${index}] must be a non-empty string array or { "value": [...] }.`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
function normalizeRelationForTablePatch(relation) {
|
|
46
56
|
const {
|
|
47
57
|
sourceTable,
|
|
@@ -102,6 +112,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
102
112
|
'Create a new table definition with an auto-included `id` primary key column.',
|
|
103
113
|
'**Not** for adding a custom API path or handler only — for that use **`create_route`** with an existing `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
|
|
104
114
|
'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
|
|
115
|
+
'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","is_read","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
|
|
105
116
|
'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id or {id}.',
|
|
106
117
|
'Do NOT provide physical FK/junction columns. Never include fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn. Enfyra derives and hides those physical columns from relation propertyName/table metadata.',
|
|
107
118
|
'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
|
|
@@ -119,13 +130,19 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
119
130
|
isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
|
|
120
131
|
columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, defaultValue?, description?, options? }. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"status","type":"enum","options":["draft","published"]}]'),
|
|
121
132
|
relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id or {"id": <id>}. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
|
|
133
|
+
indexes: z.string().optional().describe('JSON array of logical index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Example: [["member","is_read","conversation"],["conversation","member","is_read"]]'),
|
|
134
|
+
uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
|
|
122
135
|
},
|
|
123
|
-
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson }) => {
|
|
136
|
+
async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
|
|
124
137
|
const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
|
|
125
138
|
const userColumns = parseJsonArrayParam('columns', columnsJson);
|
|
126
139
|
const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
|
|
140
|
+
const indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
|
|
141
|
+
const uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
|
|
127
142
|
const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
|
|
128
143
|
if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
|
|
144
|
+
if (indexesJson !== undefined) body.indexes = indexes;
|
|
145
|
+
if (uniquesJson !== undefined) body.uniques = uniques;
|
|
129
146
|
const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
|
|
130
147
|
method: 'POST',
|
|
131
148
|
body: JSON.stringify(body),
|
|
@@ -144,8 +161,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
144
161
|
const relHint = userRelations.length
|
|
145
162
|
? `Relation(s) created in same call: ${userRelations.length}.`
|
|
146
163
|
: `No relations were included in this create_table call.`;
|
|
164
|
+
const constraintHint = [
|
|
165
|
+
indexes.length ? `Index group(s): ${indexes.length}.` : null,
|
|
166
|
+
uniques.length ? `Unique group(s): ${uniques.length}.` : null,
|
|
167
|
+
].filter(Boolean).join(' ');
|
|
147
168
|
return {
|
|
148
|
-
content: [{ type: 'text', text: `${colHint}\n${relHint}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
169
|
+
content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
149
170
|
};
|
|
150
171
|
}
|
|
151
172
|
);
|
|
@@ -155,8 +176,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
155
176
|
server.tool(
|
|
156
177
|
'update_table',
|
|
157
178
|
[
|
|
158
|
-
'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled.',
|
|
179
|
+
'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
|
|
159
180
|
'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
|
|
181
|
+
'When passing `indexes` or `uniques`, pass the complete desired array of logical field groups; omitted fields are preserved. Relation property names are allowed and are resolved by Enfyra. Example indexes: [["member","is_read","conversation"],["conversation","member","is_read"]].',
|
|
160
182
|
'Run schema changes sequentially — migration locks DB per operation.',
|
|
161
183
|
].join(' '),
|
|
162
184
|
{
|
|
@@ -166,19 +188,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
166
188
|
description: z.string().optional().describe('New description.'),
|
|
167
189
|
isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
|
|
168
190
|
graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing gql_definition.isEnabled. GraphQL still requires Bearer auth.'),
|
|
191
|
+
indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on table_definition.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
|
|
192
|
+
uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on table_definition.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
|
|
169
193
|
},
|
|
170
|
-
async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled }) => {
|
|
194
|
+
async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => {
|
|
171
195
|
const body = {};
|
|
172
196
|
if (name !== undefined) body.name = name;
|
|
173
197
|
if (alias !== undefined) body.alias = alias;
|
|
174
198
|
if (description !== undefined) body.description = description;
|
|
175
199
|
if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
|
|
176
200
|
if (graphqlEnabled !== undefined) body.graphqlEnabled = graphqlEnabled;
|
|
201
|
+
if (indexesJson !== undefined) body.indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
|
|
202
|
+
if (uniquesJson !== undefined) body.uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
|
|
177
203
|
|
|
178
|
-
const result = await
|
|
179
|
-
method: 'PATCH',
|
|
180
|
-
body: JSON.stringify(body),
|
|
181
|
-
});
|
|
204
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
|
|
182
205
|
return {
|
|
183
206
|
content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
|
|
184
207
|
};
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -591,6 +591,8 @@ server.tool(
|
|
|
591
591
|
preHook: {
|
|
592
592
|
runs: 'Before handler.',
|
|
593
593
|
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
|
|
594
|
+
queryContract: '@QUERY.filter is initialized as an object. When adding RLS or tenant filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
|
|
595
|
+
rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
|
|
594
596
|
returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
|
|
595
597
|
},
|
|
596
598
|
handler: {
|