@enfyra/mcp-server 0.0.89 → 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 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, OAuth, and proxy setup
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.89",
3
+ "version": "0.0.90",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.
@@ -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 `broadcast`.',
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.',
@@ -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
- '@SOCKET': '$ctx.$socket',
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
  },