@enfyra/mcp-server 0.0.87 → 0.0.89

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
@@ -207,14 +207,16 @@ The MCP server includes safety guards for LLM callers:
207
207
 
208
208
  - Generic record mutations validate fields against live metadata.
209
209
  - Script-backed records validate `sourceCode` through `/admin/script/validate` before saving.
210
+ - `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
210
211
  - Relation tools reject physical FK/junction names.
212
+ - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
211
213
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
212
214
  - Schema changes are serialized.
213
215
  - Destructive deletes return a preview before requiring `confirm=true`.
214
216
 
215
217
  ## Query Notes
216
218
 
217
- 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.
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. 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.
218
220
 
219
221
  ## Enfyra URL Pattern
220
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.87",
3
+ "version": "0.0.89",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -375,15 +375,20 @@ create_column({
375
375
  'Always pass fields when you need more than ids; query_table without fields intentionally returns only the primary key.',
376
376
  'Use inspect_table first when you do not know valid column names or relation propertyName values.',
377
377
  'Use count_records when only the count is needed.',
378
+ 'When the user asks for all matching rows, pass all: true instead of choosing an arbitrary page size such as 30 or 50.',
378
379
  ],
379
380
  },
380
381
  {
381
382
  name: 'List current user conversations through RLS',
382
- code: `GET /enfyra/chat_conversation?fields=id,kind,title,lastMessage.id,lastMessage.text,lastMessage.createdAt&limit=0`,
383
+ code: `query_table({
384
+ tableName: "chat_conversation",
385
+ fields: ["id", "kind", "title", "lastMessage.id", "lastMessage.text", "lastMessage.createdAt"],
386
+ all: true
387
+ })`,
383
388
  notes: [
384
389
  'Use a conversation read pre-hook/RLS boundary so the route only returns conversations visible to @USER.',
385
390
  'lastMessage is a relation to chat_message; do not duplicate preview fields on chat_conversation.',
386
- 'limit=0 means load all matching conversation rows.',
391
+ 'all: true tells MCP to send REST limit=0 and load all matching conversation rows.',
387
392
  'Do not fetch messages for every conversation on initial list load; load messages after selecting a conversation.',
388
393
  ],
389
394
  },
@@ -964,6 +969,7 @@ if (!membership.data[0]) @THROW403("Not a conversation member")
964
969
  @SOCKET.reply("chat:joined", { conversationId })`,
965
970
  notes: [
966
971
  'Join conversation rooms, not member-id rooms.',
972
+ 'conversationId is a request/room identifier; DB filters still use the relation property conversation.',
967
973
  'Check membership server-side; do not trust the client.',
968
974
  ],
969
975
  },
@@ -1004,7 +1010,8 @@ if (message?.id) {
1004
1010
 
1005
1011
  return { ok: true, message }`,
1006
1012
  notes: [
1007
- 'Do not ask the client for senderId; use @USER.id.',
1013
+ 'Do not ask the client for senderId. The sender relation is derived from @USER.id.',
1014
+ 'conversationId is accepted only as the room/business identifier; persistence uses relation properties conversation and sender, not physical FK fields.',
1008
1015
  'Event scripts should explicitly emit replies/broadcasts.',
1009
1016
  ],
1010
1017
  },
@@ -38,8 +38,9 @@ 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
+ '- **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.',
43
44
  '',
44
45
  '### Capability map (current Enfyra system)',
45
46
  '- **Schema/metadata:** `enfyra_table`, `enfyra_relation`, and schema tools manage tables, columns, relations, validation, and migrations. `enfyra_column` is internal/no-route; columns are created/updated through table schema operations.',
@@ -106,7 +107,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
106
107
  '- 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.',
107
108
  '- 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.',
108
109
  '- **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.',
109
- '- For relation CRUD payloads and generated server logic, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}` or `"author": 1`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`, e.g. `{ "author": { "_eq": 1 } }` or `{ "author": { "id": { "_eq": 1 } } }`. Do not hardcode physical FK fields such as `userId` in handlers, hooks, flows, services, or extension-adjacent code unless you are deliberately querying raw SQL outside Enfyra metadata APIs.',
110
+ '- For relation CRUD payloads and generated server logic, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}` or `"author": 1`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`, e.g. `{ "author": { "_eq": 1 } }` or `{ "author": { "id": { "_eq": 1 } } }`. Do not hardcode physical FK fields such as `userId`, `conversationId`, `senderId`, or `memberId` in handlers, hooks, flows, services, generated apps, or extension-adjacent code unless you are deliberately doing low-level raw SQL outside Enfyra metadata APIs. An API/event payload may carry a business identifier such as `conversationId`, but DB reads/writes still use relation properties such as `conversation`, `sender`, and `member`.',
110
111
  '- **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.',
111
112
  '- **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.',
112
113
  '- **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.',
@@ -160,7 +161,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
160
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.',
161
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.',
162
163
  '- `$ctx.$env` exposes only a sanitized process env snapshot. Current OSS deny keys are exact matches: `DB_URI`, `DB_REPLICA_URIS`, `REDIS_URI`, `SECRET_KEY`, and `ADMIN_PASSWORD`. Do not read secrets from `$ctx.$env`; model app secrets as unpublished `isEncrypted=true` fields instead.',
163
- '- Script-backed records use one shared persistence contract: `sourceCode` is the editable source, `scriptLanguage` controls compilation, and `compiledCode` is generated by the server from `sourceCode`. Do not hand-edit or send stale `compiledCode` from generated tools; save `sourceCode`/`scriptLanguage` through `PATCH /<script_table>/<id>` and let the server persist generated `compiledCode` internally. Public metadata may mark `compiledCode` non-updatable, but the server engine must still preserve the generated value after normalization.',
164
+ '- Script-backed records use one shared persistence contract: `sourceCode` is the editable source, `scriptLanguage` controls compilation, and `compiledCode` is generated by the server from `sourceCode`. `compiledCode` is expected to differ textually from `sourceCode` because macros such as `@USER`, `@REPOS`, and `@THROW400` are expanded during compilation; do not warn about a mismatch unless runtime behavior proves the compiled artifact is stale. Do not hand-edit or send stale `compiledCode` from generated tools; save `sourceCode`/`scriptLanguage` through `PATCH /<script_table>/<id>` and let the server persist generated `compiledCode` internally. Public metadata may mark `compiledCode` non-updatable, but the server engine must still preserve the generated value after normalization.',
164
165
  '- Use MCP `get_script_source` for full untruncated source, `patch_script_source` for focused exact edits with preview/hash validation, and `update_script_source` for full-source replacement. Use generic `update_record` only when the patch is small or includes non-script metadata fields.',
165
166
  '- For route handlers specifically, the field is also `sourceCode`. Older names such as `logic` are wrong for current Enfyra REST CRUD and will be rejected. Use MCP `create_handler` so it writes `sourceCode` and resolves method ids correctly.',
166
167
  '- MCP `create_pre_hook` and `create_post_hook` accept a user-facing `code` argument but persist it as `sourceCode` with `scriptLanguage`. Do not call raw `create_record` with a `code` field for hook tables; backend request validation rejects `code` on REST CRUD.',
@@ -175,9 +176,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
175
176
  '- Before generating a chat app, inspect live metadata for `enfyra_websocket`, `enfyra_websocket_event`, `chat_conversation`, `chat_conversation_member`, and `chat_message`. Do not assume table names, reverse relations, route permissions, or physical field casing.',
176
177
  '- 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.',
177
178
  '- For authenticated realtime clients, create the socket as an application singleton from a client-only plugin/bootstrap once auth has resolved. Watch the shared current-user/session state: connect when a user exists, disconnect when the session clears, and let route components subscribe/unsubscribe listeners instead of owning the connection lifecycle.',
178
- '- On an authenticated gateway, Enfyra loads `enfyra_user` 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.',
179
+ '- On an authenticated gateway, Enfyra loads `enfyra_user` once for the socket and event scripts receive `@USER`. The server also joins `user_<userId>` after the connection script succeeds. Event scripts must not ask the client to send `senderId`; derive the sender relation from `@USER.id`. `chat:join` does not need to join `user_<userId>` again.',
179
180
  '- 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.',
180
- '- `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.',
181
+ '- `chat:message` may accept a request/room identifier such as `conversationId`, but persistence must use relation properties: `conversation: { id: conversationId }` and `sender: { id: @USER.id }`. 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.',
181
182
  '- 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.',
182
183
  '- 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.',
183
184
  '- 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.',
@@ -1012,17 +1012,24 @@ server.tool(
1012
1012
  },
1013
1013
  );
1014
1014
 
1015
- server.tool('query_table', 'Query any route-backed table. Default response is minimal; pass fields explicitly for detail.', {
1015
+ 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
1016
  tableName: z.string().describe('Table name to query'),
1017
1017
  filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
1018
1018
  sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
1019
1019
  page: z.number().optional().describe('Page number (default: 1)'),
1020
- limit: z.number().optional().describe('Items per page. Default: 10. Use count_records for counts.'),
1020
+ 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.'),
1021
+ 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
1022
  fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
1022
1023
  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
1024
  deep: z.string().optional().describe('Optional deep relation fetch object as JSON string. Keys must be relation propertyName values.'),
1024
1025
  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 }) => {
1026
+ }, async ({ tableName, filter, sort, page, limit, all, fields, meta, deep, aggregate }) => {
1027
+ if (!all && limit === undefined) {
1028
+ throw new Error('query_table requires either limit or all=true. Do not rely on implicit default page sizes.');
1029
+ }
1030
+ if (all && limit !== undefined) {
1031
+ throw new Error('query_table accepts either all=true or limit, not both.');
1032
+ }
1026
1033
  validateTableName(tableName);
1027
1034
  validateFilter(filter);
1028
1035
  parseJsonArg(deep, undefined);
@@ -1036,7 +1043,8 @@ server.tool('query_table', 'Query any route-backed table. Default response is mi
1036
1043
  if (meta) queryParams.set('meta', meta);
1037
1044
  if (deep) queryParams.set('deep', deep);
1038
1045
  if (aggregate) queryParams.set('aggregate', aggregate);
1039
- queryParams.set('limit', String(limit || 10));
1046
+ const effectiveLimit = all ? 0 : limit;
1047
+ queryParams.set('limit', String(effectiveLimit));
1040
1048
  queryParams.set('fields', selectedFields.join(','));
1041
1049
 
1042
1050
  const query = queryParams.toString();
@@ -1046,7 +1054,8 @@ server.tool('query_table', 'Query any route-backed table. Default response is mi
1046
1054
  success: result?.success,
1047
1055
  tableName,
1048
1056
  fields: selectedFields,
1049
- limit: limit || 10,
1057
+ limit: effectiveLimit,
1058
+ all: !!all,
1050
1059
  queryOptions: {
1051
1060
  meta: meta || null,
1052
1061
  deep: deep ? parseJsonArg(deep, null) : null,
@@ -2014,11 +2023,18 @@ server.tool(
2014
2023
  },
2015
2024
  );
2016
2025
 
2017
- server.tool('get_all_routes', 'List route definitions with minimal fields. Call inspect_route for handlers/hooks/permissions detail.', {
2026
+ 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
2027
  includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
2019
2028
  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. Default 50 to keep response small.'),
2021
- }, async ({ includeDisabled, search, limit }) => {
2029
+ 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.'),
2030
+ 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.'),
2031
+ }, async ({ includeDisabled, search, limit, all }) => {
2032
+ if (!all && limit === undefined) {
2033
+ throw new Error('get_all_routes requires either limit or all=true. Do not rely on implicit default page sizes.');
2034
+ }
2035
+ if (all && limit !== undefined) {
2036
+ throw new Error('get_all_routes accepts either all=true or limit, not both.');
2037
+ }
2022
2038
  const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
2023
2039
  const queryParams = new URLSearchParams({
2024
2040
  filter: JSON.stringify(filter),
@@ -2026,7 +2042,6 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
2026
2042
  limit: '1000',
2027
2043
  });
2028
2044
  const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${queryParams.toString()}`);
2029
- const routeLimit = limit || 50;
2030
2045
  const q = search ? search.toLowerCase() : null;
2031
2046
  const allRoutes = summarizeRoutes(result);
2032
2047
  const matchedRoutes = q
@@ -2035,12 +2050,14 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
2035
2050
  mainTable: route.mainTable,
2036
2051
  }).toLowerCase().includes(q))
2037
2052
  : allRoutes;
2053
+ const routeLimit = all ? matchedRoutes.length : limit;
2038
2054
  const payload = {
2039
2055
  statusCode: result?.statusCode,
2040
2056
  success: result?.success,
2041
2057
  totalRouteCount: allRoutes.length,
2042
2058
  matchedRouteCount: matchedRoutes.length,
2043
2059
  returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
2060
+ all: !!all,
2044
2061
  search: search || null,
2045
2062
  routes: matchedRoutes.slice(0, routeLimit),
2046
2063
  detailHint: matchedRoutes.length > routeLimit