@enfyra/mcp-server 0.0.88 → 0.0.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -26
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +97 -2
- package/src/lib/mcp-instructions.js +4 -3
- package/src/mcp-server-entry.mjs +66 -28
package/README.md
CHANGED
|
@@ -168,7 +168,7 @@ For normal apps and demos, enter the app/admin URL such as `http://localhost:300
|
|
|
168
168
|
|
|
169
169
|
Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate implementation patterns. It returns focused examples for:
|
|
170
170
|
|
|
171
|
-
- SSR app auth
|
|
171
|
+
- SSR app auth and proxy setup
|
|
172
172
|
- schema, columns, relations, indexes, and validation
|
|
173
173
|
- query filters, sorting, fields, deep relations, and aggregates
|
|
174
174
|
- handlers, hooks, permissions, and RLS
|
|
@@ -177,30 +177,6 @@ Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate
|
|
|
177
177
|
- files and storage
|
|
178
178
|
- Enfyra admin extensions
|
|
179
179
|
|
|
180
|
-
## OAuth Setup
|
|
181
|
-
|
|
182
|
-
OAuth has three different URLs:
|
|
183
|
-
|
|
184
|
-
| URL | Meaning |
|
|
185
|
-
|-----|---------|
|
|
186
|
-
| Provider callback URL | `{ENFYRA_API_URL}/auth/{provider}/callback` |
|
|
187
|
-
| Enfyra `redirectUri` | Must exactly match the provider callback URL |
|
|
188
|
-
| App `redirect` query | Where Enfyra sends the browser after cookies are set |
|
|
189
|
-
|
|
190
|
-
Example Google callback when `ENFYRA_API_URL=http://localhost:3000/api`:
|
|
191
|
-
|
|
192
|
-
```text
|
|
193
|
-
http://localhost:3000/api/auth/google/callback
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
Start OAuth from the app proxy:
|
|
197
|
-
|
|
198
|
-
```text
|
|
199
|
-
/enfyra/auth/google?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
`appCallbackUrl` is only for manual-token apps that intentionally read token query parameters. SSR apps should prefer proxy-owned cookies.
|
|
203
|
-
|
|
204
180
|
## Runtime Safety
|
|
205
181
|
|
|
206
182
|
The MCP server includes safety guards for LLM callers:
|
|
@@ -216,7 +192,7 @@ The MCP server includes safety guards for LLM callers:
|
|
|
216
192
|
|
|
217
193
|
## Query Notes
|
|
218
194
|
|
|
219
|
-
Use explicit `fields` in read tools. Include mode is the default, such as `fields=id,email`. Any excluded field switches that scope to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Dotted exclusions such as `fields=-owner.avatar` work for relation fields when the relation exists in metadata.
|
|
195
|
+
Use explicit `fields` in read tools. Include mode is the default, such as `fields=id,email`. Any excluded field switches that scope to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Dotted exclusions such as `fields=-owner.avatar` work for relation fields when the relation exists in metadata. Every list/query call must pass either `limit` for a bounded page or `all: true` for a complete list. When a caller needs every matching row, pass `all: true` to `query_table` or `get_all_routes`; the tool sends REST `limit=0` instead of making the model choose an arbitrary page size like 30 or 50.
|
|
220
196
|
|
|
221
197
|
## Enfyra URL Pattern
|
|
222
198
|
|
package/package.json
CHANGED
package/src/lib/mcp-examples.js
CHANGED
|
@@ -153,6 +153,96 @@ onUnmounted(() => {
|
|
|
153
153
|
'Disconnect the singleton socket when the current user/session clears.',
|
|
154
154
|
],
|
|
155
155
|
},
|
|
156
|
+
{
|
|
157
|
+
name: 'Next client provider for authenticated realtime',
|
|
158
|
+
code: `"use client"
|
|
159
|
+
|
|
160
|
+
// app/realtime-provider.tsx
|
|
161
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"
|
|
162
|
+
import { io, type Socket } from "socket.io-client"
|
|
163
|
+
|
|
164
|
+
type RealtimeContextValue = {
|
|
165
|
+
socket: Socket | null
|
|
166
|
+
isConnected: boolean
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const RealtimeContext = createContext<RealtimeContextValue>({
|
|
170
|
+
socket: null,
|
|
171
|
+
isConnected: false
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
export function RealtimeProvider({
|
|
175
|
+
user,
|
|
176
|
+
children
|
|
177
|
+
}: {
|
|
178
|
+
user: { id: string | number } | null
|
|
179
|
+
children: React.ReactNode
|
|
180
|
+
}) {
|
|
181
|
+
const socketRef = useRef<Socket | null>(null)
|
|
182
|
+
const [isConnected, setConnected] = useState(false)
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!user) {
|
|
186
|
+
socketRef.current?.disconnect()
|
|
187
|
+
socketRef.current = null
|
|
188
|
+
setConnected(false)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (socketRef.current) return
|
|
193
|
+
|
|
194
|
+
const socket = io("/chat", {
|
|
195
|
+
path: "/socket.io",
|
|
196
|
+
withCredentials: true,
|
|
197
|
+
reconnection: true,
|
|
198
|
+
reconnectionAttempts: Infinity,
|
|
199
|
+
reconnectionDelay: 2000,
|
|
200
|
+
reconnectionDelayMax: 30000
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
socket.on("connect", () => setConnected(true))
|
|
204
|
+
socket.on("disconnect", () => setConnected(false))
|
|
205
|
+
socketRef.current = socket
|
|
206
|
+
|
|
207
|
+
return () => {
|
|
208
|
+
socket.off("connect")
|
|
209
|
+
socket.off("disconnect")
|
|
210
|
+
socket.disconnect()
|
|
211
|
+
socketRef.current = null
|
|
212
|
+
setConnected(false)
|
|
213
|
+
}
|
|
214
|
+
}, [user])
|
|
215
|
+
|
|
216
|
+
const value = useMemo(
|
|
217
|
+
() => ({ socket: socketRef.current, isConnected }),
|
|
218
|
+
[isConnected]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function useRealtime() {
|
|
225
|
+
return useContext(RealtimeContext)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// app/chat/page.tsx
|
|
229
|
+
// const { socket } = useRealtime()
|
|
230
|
+
// useEffect(() => {
|
|
231
|
+
// if (!socket) return
|
|
232
|
+
// const onMessage = event => {
|
|
233
|
+
// // Update local UI state, then debounce REST refresh if full state is needed.
|
|
234
|
+
// }
|
|
235
|
+
// socket.on("chat:message", onMessage)
|
|
236
|
+
// return () => socket.off("chat:message", onMessage)
|
|
237
|
+
// }, [socket])`,
|
|
238
|
+
notes: [
|
|
239
|
+
'Create the Socket.IO client once in a top-level client provider after the current user is known.',
|
|
240
|
+
'Use the websocket namespace path from live metadata, such as /chat, and keep the transport path as /socket.io.',
|
|
241
|
+
'Proxy /socket.io through Next rewrites to the Enfyra app bridge /ws/socket.io so cookies remain same-origin.',
|
|
242
|
+
'Pages/components should only subscribe/unsubscribe listeners; they should not create independent socket connections.',
|
|
243
|
+
'Disconnect the singleton socket when the current user/session clears.',
|
|
244
|
+
],
|
|
245
|
+
},
|
|
156
246
|
{
|
|
157
247
|
name: 'OAuth provider setup values',
|
|
158
248
|
code: `// Enfyra OAuth config row, stored in enfyra_oauth_config.
|
|
@@ -375,15 +465,20 @@ create_column({
|
|
|
375
465
|
'Always pass fields when you need more than ids; query_table without fields intentionally returns only the primary key.',
|
|
376
466
|
'Use inspect_table first when you do not know valid column names or relation propertyName values.',
|
|
377
467
|
'Use count_records when only the count is needed.',
|
|
468
|
+
'When the user asks for all matching rows, pass all: true instead of choosing an arbitrary page size such as 30 or 50.',
|
|
378
469
|
],
|
|
379
470
|
},
|
|
380
471
|
{
|
|
381
472
|
name: 'List current user conversations through RLS',
|
|
382
|
-
code: `
|
|
473
|
+
code: `query_table({
|
|
474
|
+
tableName: "chat_conversation",
|
|
475
|
+
fields: ["id", "kind", "title", "lastMessage.id", "lastMessage.text", "lastMessage.createdAt"],
|
|
476
|
+
all: true
|
|
477
|
+
})`,
|
|
383
478
|
notes: [
|
|
384
479
|
'Use a conversation read pre-hook/RLS boundary so the route only returns conversations visible to @USER.',
|
|
385
480
|
'lastMessage is a relation to chat_message; do not duplicate preview fields on chat_conversation.',
|
|
386
|
-
'limit=0
|
|
481
|
+
'all: true tells MCP to send REST limit=0 and load all matching conversation rows.',
|
|
387
482
|
'Do not fetch messages for every conversation on initial list load; load messages after selecting a conversation.',
|
|
388
483
|
],
|
|
389
484
|
},
|
|
@@ -38,7 +38,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
38
38
|
'- If generating concrete code, schema payloads, SSR app config, OAuth wiring, Socket.IO clients/events, flows, files, extensions, or permission/RLS examples, call **`get_enfyra_examples`** for the matching category before writing the final answer. Examples are grouped by category and are intentionally more concrete than these global rules.',
|
|
39
39
|
'- Treat hardcoded instructions as operating rules, but use live discovery as the final check for this running instance. Do not infer missing capabilities from a narrow tool schema; check metadata/routes or the relevant specialized tool first.',
|
|
40
40
|
'- If there is no dedicated MCP tool for a subsystem, use the route-backed metadata table with `query_table` / `create_record` / `update_record` / `delete_record`, after confirming that table has a route. If the table is no-route, use the canonical specialized tool or parent table workflow instead.',
|
|
41
|
-
'- MCP read tools are intentionally **minimal by default**. `query_table` without `fields` returns only the table primary key with a small hint. Always pass explicit `fields` when you need details, and use `inspect_table` / `inspect_route` before guessing field names.',
|
|
41
|
+
'- MCP read tools are intentionally **minimal by default**. `query_table` without `fields` returns only the table primary key with a small hint. Always pass explicit `fields` when you need details, and use `inspect_table` / `inspect_route` before guessing field names. Every list/query call must explicitly pass either `limit` for a bounded page or `all: true` for a complete list. When the user asks for all matching rows, pass `all: true` instead of inventing arbitrary limits such as 30 or 50.',
|
|
42
42
|
'- MCP mutation tools return only ids/status by default. If you need the saved row, immediately call `find_one_record` or `query_table` with explicit `fields`; do not expect create/update tools to echo full records.',
|
|
43
43
|
'- **Operator posture:** act from the Enfyra contracts encoded here and in live metadata. Do not turn normal implementation details into speculative warnings. Ask the user only when a new design/product decision is needed, when metadata is genuinely ambiguous, or when a tool/runtime result proves a concrete problem. If a behavior is expected by contract, state it as expected behavior or omit it; do not present it as an audit finding.',
|
|
44
44
|
'',
|
|
@@ -156,7 +156,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
156
156
|
'- You may **mutate** `@DATA` / `$ctx.$data` in place, or **return** a value: a non-`undefined` return replaces `$ctx.$data` as the response body.',
|
|
157
157
|
'',
|
|
158
158
|
'### Dynamic script syntax preference',
|
|
159
|
-
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@STORAGE`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, `@ENV`, and `@THROW400`–`@THROW503
|
|
159
|
+
'- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REQ`, `@RES`, `@REPOS`, `@HELPERS`, `@FETCH`, `@STORAGE`, `@UPLOADED_FILE`, `@CACHE`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, `@STATUS`, `@ENV`, `@PKGS`, `@LOGS`, `@SHARE`, `@API`, and `@THROW` / `@THROW400`–`@THROW503` when those context fields are available.',
|
|
160
160
|
'- Use Enfyra native throw helpers for intentional errors: `@THROW400("message")`, `@THROW403()`, `@THROW404("resource", id)`, or `$ctx.$throw[400]("message")`. Do not generate `throw new Error(...)` for user/domain errors in handlers, hooks, flows, websocket events, OAuth scripts, or admin-generated scripts.',
|
|
161
161
|
'- For regular app data that must be encrypted at rest, create the column with `isEncrypted=true`; Enfyra database-query hooks will encrypt on insert/update and decrypt after select. `isEncrypted` does not imply immutability; use `isUpdatable=false` separately only when the field itself must be immutable. Do not filter or sort on encrypted fields. Do not generate new route pre-hooks for manual encryption.',
|
|
162
162
|
'- Enfyra scripts use `$helpers.$crypto` for bounded crypto helpers such as `randomUUID()`, `randomBytes(size, encoding)`, `sha256(value, encoding)`, `hmacSha256(value, secret, encoding)`, and `generateSshKeyPair(comment)`. Do not generate legacy `$helpers.$ssh` or `$helpers.$secrets` usage.',
|
|
@@ -288,8 +288,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
288
288
|
'- `@SOCKET.broadcastToRoom(room, event, data)` — send to a named room in the current websocket gateway/namespace except the triggering socket (WS context only).',
|
|
289
289
|
'- `@SOCKET.emitToGateway(path, event, data)` — broadcast to all connections on a gateway/namespace.',
|
|
290
290
|
'- `@SOCKET.broadcast(event, data)` — broadcast to all connections on all gateways.',
|
|
291
|
+
'- `await @SOCKET.roomSize(room)` — count connected sockets in a room across registered gateways; available in server script socket context.',
|
|
291
292
|
'- `@SOCKET.disconnect()` — force-disconnect the current socket from the gateway (WS context only). Use in connection handler to reject, or in event handler to kick user.',
|
|
292
|
-
'- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect`, `emitToCurrentRoom`, and `broadcastToRoom` are not available (no bound socket). Use `emitToUser`, `emitToRoom(path, room, event, data)`, `emitToGateway`, and `
|
|
293
|
+
'- In **HTTP** handler/hook context: `reply`, `join`, `leave`, `disconnect`, `emitToCurrentRoom`, and `broadcastToRoom` are not available (no bound socket). Use `emitToUser`, `emitToRoom(path, room, event, data)`, `emitToGateway`, `broadcast`, and `roomSize`.',
|
|
293
294
|
'- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
|
|
294
295
|
'- **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`.',
|
|
295
296
|
'- **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.',
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -866,25 +866,44 @@ server.tool(
|
|
|
866
866
|
const payload = {
|
|
867
867
|
transformer: {
|
|
868
868
|
rule: 'Dynamic server scripts are transformed before sandbox execution. Macros expand to $ctx paths; comments are not transformed.',
|
|
869
|
-
preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use @BODY/@QUERY/@PARAMS/@USER/@REPOS/@CACHE/@HELPERS/@STORAGE/@SOCKET/@TRIGGER/@DATA/@ERROR/@ENV/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
|
|
869
|
+
preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use macros such as @BODY/@QUERY/@PARAMS/@USER/@REQ/@RES/@REPOS/@CACHE/@HELPERS/@FETCH/@STORAGE/@UPLOADED_FILE/@SOCKET/@TRIGGER/@DATA/@ERROR/@STATUS/@ENV/@PKGS/@LOGS/@SHARE/@API/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
|
|
870
870
|
coreMacros: {
|
|
871
|
-
'@BODY': '$ctx.$body',
|
|
872
|
-
'@QUERY': '$ctx.$query',
|
|
873
|
-
'@PARAMS': '$ctx.$params',
|
|
874
|
-
'@USER': '$ctx.$user',
|
|
875
|
-
'@ENV': '$ctx.$env',
|
|
876
|
-
'@REPOS': '$ctx.$repos',
|
|
877
871
|
'@CACHE': '$ctx.$cache',
|
|
872
|
+
'@REPOS': '$ctx.$repos',
|
|
878
873
|
'@HELPERS': '$ctx.$helpers',
|
|
879
874
|
'@STORAGE': '$ctx.$storage',
|
|
880
|
-
'@
|
|
875
|
+
'@FETCH': '$ctx.$helpers.$fetch',
|
|
876
|
+
'@LOGS': '$ctx.$logs',
|
|
877
|
+
'@BODY': '$ctx.$body',
|
|
878
|
+
'@ENV': '$ctx.$env',
|
|
881
879
|
'@DATA': '$ctx.$data',
|
|
880
|
+
'@PARAMS': '$ctx.$params',
|
|
881
|
+
'@QUERY': '$ctx.$query',
|
|
882
|
+
'@USER': '$ctx.$user',
|
|
883
|
+
'@REQ': '$ctx.$req',
|
|
884
|
+
'@RES': '$ctx.$res',
|
|
885
|
+
'@SHARE': '$ctx.$share',
|
|
886
|
+
'@API': '$ctx.$api',
|
|
887
|
+
'@UPLOADED_FILE': '$ctx.$uploadedFile',
|
|
888
|
+
'@PKGS': '$ctx.$pkgs',
|
|
889
|
+
'@SOCKET': '$ctx.$socket',
|
|
890
|
+
'@TRIGGER': '$ctx.$trigger',
|
|
891
|
+
'@FLOW': '$ctx.$flow',
|
|
892
|
+
'@FLOW_PAYLOAD': '$ctx.$flow.$payload',
|
|
893
|
+
'@FLOW_LAST': '$ctx.$flow.$last',
|
|
894
|
+
'@FLOW_META': '$ctx.$flow.$meta',
|
|
895
|
+
'@THROW400': "$ctx.$throw['400']",
|
|
896
|
+
'@THROW401': "$ctx.$throw['401']",
|
|
897
|
+
'@THROW403': "$ctx.$throw['403']",
|
|
898
|
+
'@THROW404': "$ctx.$throw['404']",
|
|
899
|
+
'@THROW409': "$ctx.$throw['409']",
|
|
900
|
+
'@THROW422': "$ctx.$throw['422']",
|
|
901
|
+
'@THROW429': "$ctx.$throw['429']",
|
|
902
|
+
'@THROW500': "$ctx.$throw['500']",
|
|
903
|
+
'@THROW503': "$ctx.$throw['503']",
|
|
904
|
+
'@THROW': '$ctx.$throw',
|
|
882
905
|
'@STATUS': '$ctx.$statusCode',
|
|
883
906
|
'@ERROR': '$ctx.$error',
|
|
884
|
-
'@PKGS': '$ctx.$pkgs',
|
|
885
|
-
'@LOGS': '$ctx.$logs',
|
|
886
|
-
'@SHARE': '$ctx.$share',
|
|
887
|
-
'@TRIGGER(name,payload)': '$ctx.$trigger(name,payload)',
|
|
888
907
|
},
|
|
889
908
|
flowMacros: {
|
|
890
909
|
'@FLOW': '$ctx.$flow',
|
|
@@ -900,6 +919,8 @@ server.tool(
|
|
|
900
919
|
},
|
|
901
920
|
throws: '@THROW400 through @THROW503 and @THROW map to $ctx.$throw helpers.',
|
|
902
921
|
helpers: {
|
|
922
|
+
core: '$ctx.$helpers includes $bcrypt.hash/compare, autoSlug(text), $fetch, $sleep(ms) capped by the runtime, and $crypto. HTTP and GraphQL contexts also expose $jwt through $ctx.$helpers.',
|
|
923
|
+
fetch: '@FETCH maps to $ctx.$helpers.$fetch for outbound HTTP calls from server scripts. Keep secrets in encrypted fields instead of embedding them in sourceCode.',
|
|
903
924
|
crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Use generateSshKeyPair for SSH key material. Do not use legacy $ctx.$helpers.$ssh.',
|
|
904
925
|
files: '$ctx.$storage.$upload and $ctx.$storage.$update accept file: @UPLOADED_FILE for request uploads and stream from the server temp file path. $ctx.$storage.$registerFile creates a enfyra_file record for an object that already exists in storage without uploading bytes. Use buffer only for small generated/transformed files; do not use @UPLOADED_FILE.buffer.',
|
|
905
926
|
},
|
|
@@ -908,7 +929,7 @@ server.tool(
|
|
|
908
929
|
contexts: {
|
|
909
930
|
preHook: {
|
|
910
931
|
runs: 'Before handler.',
|
|
911
|
-
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@STORAGE', '@THROW*', '@SOCKET emit helpers'],
|
|
932
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@REPOS', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@THROW*', '@SOCKET global emit helpers/roomSize'],
|
|
912
933
|
queryContract: '@QUERY.filter is initialized as an object. When adding RLS/scope filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
|
|
913
934
|
projectionContract: 'For canonical table reads, preserve client-controlled query shape. Do not override @QUERY.fields, @QUERY.deep, @QUERY.sort, @QUERY.limit, @QUERY.page, @QUERY.meta, @QUERY.aggregate, or debugMode. RLS should only merge security constraints into @QUERY.filter.',
|
|
914
935
|
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;',
|
|
@@ -916,28 +937,28 @@ server.tool(
|
|
|
916
937
|
},
|
|
917
938
|
handler: {
|
|
918
939
|
runs: 'Main route logic, or canonical CRUD if no handler overrides.',
|
|
919
|
-
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main', '@REPOS.<table>', '@CACHE', '@HELPERS', '@STORAGE', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
|
|
940
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@RES when response streaming is available', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main', '@REPOS.<table>', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@PKGS', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
|
|
920
941
|
queryContract: 'When a handler wraps a canonical table read, pass through client fields/deep/sort/page/limit/meta/aggregate/debugMode unless the route is a clearly custom summary or workflow endpoint.',
|
|
921
942
|
returnBehavior: 'Return value becomes response body unless post-hook changes it.',
|
|
922
943
|
},
|
|
923
944
|
postHook: {
|
|
924
945
|
runs: 'After handler, including error path.',
|
|
925
|
-
data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@USER', '@CACHE', '@SHARE', '@API'],
|
|
946
|
+
data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@SHARE', '@API'],
|
|
926
947
|
returnBehavior: 'Mutate @DATA/$ctx.$data or return a non-undefined replacement response.',
|
|
927
948
|
},
|
|
928
949
|
flowStep: {
|
|
929
950
|
runs: 'Inside flow execution or admin flow step test.',
|
|
930
|
-
data: ['@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@STORAGE', '@SOCKET', '@TRIGGER'],
|
|
951
|
+
data: ['@BODY payload', '@USER if provided', '@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
|
|
931
952
|
resultBehavior: 'Step return value is injected into @FLOW.<step.key> and @FLOW_LAST.',
|
|
932
953
|
branching: 'Condition steps use JavaScript truthy/falsy result; child branch is true/false.',
|
|
933
954
|
},
|
|
934
955
|
websocketConnection: {
|
|
935
956
|
runs: 'Socket.IO connection handler.',
|
|
936
|
-
data: ['@BODY connection info', '@USER if authenticated', '@SOCKET reply/join/leave/disconnect/emit helpers'],
|
|
957
|
+
data: ['@BODY connection info', '@DATA connection info', '@REQ websocket request metadata', '@API request metadata', '@USER if authenticated', '@HELPERS', '@FETCH', '@SOCKET reply/join/leave/disconnect/emit helpers/roomSize'],
|
|
937
958
|
},
|
|
938
959
|
websocketEvent: {
|
|
939
960
|
runs: 'Socket.IO event handler.',
|
|
940
|
-
data: ['@BODY event payload', '@USER if authenticated', '@SOCKET reply/join/leave/disconnect/emit helpers'],
|
|
961
|
+
data: ['@BODY event payload', '@DATA event payload', '@REQ websocket request metadata', '@API request metadata', '@USER if authenticated', '@HELPERS', '@FETCH', '@SOCKET reply/join/leave/disconnect/emit helpers/roomSize'],
|
|
941
962
|
resultBehavior: 'Client ack receives queued state first; handler result is emitted asynchronously as ws:result/ws:error with requestId.',
|
|
942
963
|
},
|
|
943
964
|
graphqlResolver: {
|
|
@@ -959,7 +980,7 @@ server.tool(
|
|
|
959
980
|
wrongSingleRecordAccess: 'Do not use result.data.id, do not return result.data when one object is expected, and do not assume create/update returns the bare row object.',
|
|
960
981
|
countPattern: 'To count records in custom code, do not fetch full rows. Use const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); then read result.meta.filterCount or result.meta.totalCount.',
|
|
961
982
|
},
|
|
962
|
-
socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect/emitToCurrentRoom/broadcastToRoom because there is no bound socket. emitToRoom requires an explicit gateway path: emitToRoom(path, room, event, data).',
|
|
983
|
+
socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast and roomSize, but cannot reply/join/leave/disconnect/emitToCurrentRoom/broadcastToRoom because there is no bound socket. emitToRoom requires an explicit gateway path: emitToRoom(path, room, event, data). roomSize(room) counts sockets in that room across registered gateways.',
|
|
963
984
|
packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
|
|
964
985
|
files: 'Upload helpers are on $storage; raw create_record on enfyra_file is not equivalent to multipart upload/storage rollback. For multipart request files, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from disk-backed temp storage. Use @STORAGE.$registerFile only when the object already exists in storage and the script should create the enfyra_file record without uploading bytes. Use buffer only for small generated files.',
|
|
965
986
|
},
|
|
@@ -1012,17 +1033,24 @@ server.tool(
|
|
|
1012
1033
|
},
|
|
1013
1034
|
);
|
|
1014
1035
|
|
|
1015
|
-
server.tool('query_table', 'Query any route-backed table.
|
|
1036
|
+
server.tool('query_table', 'Query any route-backed table. Response is minimal unless fields is explicit. Every call must pass either limit or all=true.', {
|
|
1016
1037
|
tableName: z.string().describe('Table name to query'),
|
|
1017
1038
|
filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
|
|
1018
1039
|
sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
|
|
1019
1040
|
page: z.number().optional().describe('Page number (default: 1)'),
|
|
1020
|
-
limit: z.number().optional().describe('Items per page.
|
|
1041
|
+
limit: z.number().int().min(0).optional().describe('Items per page. Required unless all=true. Do not invent arbitrary limits for "all"; use all=true instead. Use count_records for counts.'),
|
|
1042
|
+
all: z.boolean().optional().default(false).describe('Return all matching rows by sending REST limit=0. Use this when the user asks for all rows or a complete list.'),
|
|
1021
1043
|
fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
|
|
1022
1044
|
meta: z.string().optional().describe('Optional REST meta request, e.g. "totalCount", "filterCount", or aggregate modes supported by the route. Use count_records for simple counts.'),
|
|
1023
1045
|
deep: z.string().optional().describe('Optional deep relation fetch object as JSON string. Keys must be relation propertyName values.'),
|
|
1024
1046
|
aggregate: z.string().optional().describe('Optional aggregate object as JSON string, keyed by real fields/relations. Results are returned in response.meta.aggregate when supported.'),
|
|
1025
|
-
}, async ({ tableName, filter, sort, page, limit, fields, meta, deep, aggregate }) => {
|
|
1047
|
+
}, async ({ tableName, filter, sort, page, limit, all, fields, meta, deep, aggregate }) => {
|
|
1048
|
+
if (!all && limit === undefined) {
|
|
1049
|
+
throw new Error('query_table requires either limit or all=true. Do not rely on implicit default page sizes.');
|
|
1050
|
+
}
|
|
1051
|
+
if (all && limit !== undefined) {
|
|
1052
|
+
throw new Error('query_table accepts either all=true or limit, not both.');
|
|
1053
|
+
}
|
|
1026
1054
|
validateTableName(tableName);
|
|
1027
1055
|
validateFilter(filter);
|
|
1028
1056
|
parseJsonArg(deep, undefined);
|
|
@@ -1036,7 +1064,8 @@ server.tool('query_table', 'Query any route-backed table. Default response is mi
|
|
|
1036
1064
|
if (meta) queryParams.set('meta', meta);
|
|
1037
1065
|
if (deep) queryParams.set('deep', deep);
|
|
1038
1066
|
if (aggregate) queryParams.set('aggregate', aggregate);
|
|
1039
|
-
|
|
1067
|
+
const effectiveLimit = all ? 0 : limit;
|
|
1068
|
+
queryParams.set('limit', String(effectiveLimit));
|
|
1040
1069
|
queryParams.set('fields', selectedFields.join(','));
|
|
1041
1070
|
|
|
1042
1071
|
const query = queryParams.toString();
|
|
@@ -1046,7 +1075,8 @@ server.tool('query_table', 'Query any route-backed table. Default response is mi
|
|
|
1046
1075
|
success: result?.success,
|
|
1047
1076
|
tableName,
|
|
1048
1077
|
fields: selectedFields,
|
|
1049
|
-
limit:
|
|
1078
|
+
limit: effectiveLimit,
|
|
1079
|
+
all: !!all,
|
|
1050
1080
|
queryOptions: {
|
|
1051
1081
|
meta: meta || null,
|
|
1052
1082
|
deep: deep ? parseJsonArg(deep, null) : null,
|
|
@@ -2014,11 +2044,18 @@ server.tool(
|
|
|
2014
2044
|
},
|
|
2015
2045
|
);
|
|
2016
2046
|
|
|
2017
|
-
server.tool('get_all_routes', 'List route definitions with minimal fields. Call inspect_route for handlers/hooks/permissions detail.', {
|
|
2047
|
+
server.tool('get_all_routes', 'List route definitions with minimal fields. Every call must pass either limit or all=true. Call inspect_route for handlers/hooks/permissions detail.', {
|
|
2018
2048
|
includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
|
|
2019
2049
|
search: z.string().optional().describe('Optional path or table substring filter. Use this before creating a route to check duplicates.'),
|
|
2020
|
-
limit: z.number().optional().describe('Maximum routes returned after search.
|
|
2021
|
-
|
|
2050
|
+
limit: z.number().int().positive().optional().describe('Maximum routes returned after search. Required unless all=true. Do not invent arbitrary limits for "all"; use all=true instead.'),
|
|
2051
|
+
all: z.boolean().optional().default(false).describe('Return all matched routes. Use this when the user asks for all routes or a complete route list.'),
|
|
2052
|
+
}, async ({ includeDisabled, search, limit, all }) => {
|
|
2053
|
+
if (!all && limit === undefined) {
|
|
2054
|
+
throw new Error('get_all_routes requires either limit or all=true. Do not rely on implicit default page sizes.');
|
|
2055
|
+
}
|
|
2056
|
+
if (all && limit !== undefined) {
|
|
2057
|
+
throw new Error('get_all_routes accepts either all=true or limit, not both.');
|
|
2058
|
+
}
|
|
2022
2059
|
const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
|
|
2023
2060
|
const queryParams = new URLSearchParams({
|
|
2024
2061
|
filter: JSON.stringify(filter),
|
|
@@ -2026,7 +2063,6 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
|
|
|
2026
2063
|
limit: '1000',
|
|
2027
2064
|
});
|
|
2028
2065
|
const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${queryParams.toString()}`);
|
|
2029
|
-
const routeLimit = limit || 50;
|
|
2030
2066
|
const q = search ? search.toLowerCase() : null;
|
|
2031
2067
|
const allRoutes = summarizeRoutes(result);
|
|
2032
2068
|
const matchedRoutes = q
|
|
@@ -2035,12 +2071,14 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
|
|
|
2035
2071
|
mainTable: route.mainTable,
|
|
2036
2072
|
}).toLowerCase().includes(q))
|
|
2037
2073
|
: allRoutes;
|
|
2074
|
+
const routeLimit = all ? matchedRoutes.length : limit;
|
|
2038
2075
|
const payload = {
|
|
2039
2076
|
statusCode: result?.statusCode,
|
|
2040
2077
|
success: result?.success,
|
|
2041
2078
|
totalRouteCount: allRoutes.length,
|
|
2042
2079
|
matchedRouteCount: matchedRoutes.length,
|
|
2043
2080
|
returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
|
|
2081
|
+
all: !!all,
|
|
2044
2082
|
search: search || null,
|
|
2045
2083
|
routes: matchedRoutes.slice(0, routeLimit),
|
|
2046
2084
|
detailHint: matchedRoutes.length > routeLimit
|