@enfyra/mcp-server 0.0.86 → 0.0.88

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,7 +207,9 @@ 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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.86",
3
+ "version": "0.0.88",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -736,6 +736,81 @@ ensure_route_access({
736
736
  'Route permissions apply when the method is not public.',
737
737
  ],
738
738
  },
739
+ {
740
+ name: 'Rate limit anonymous requests by IP',
741
+ code: `create_guard({
742
+ name: "Public signup IP rate limit",
743
+ path: "/newsletter_signup",
744
+ methods: ["POST"],
745
+ position: "pre_auth",
746
+ isEnabled: true,
747
+ description: "Limit anonymous signup attempts by client IP.",
748
+ rules: JSON.stringify([
749
+ {
750
+ type: "rate_limit_by_ip",
751
+ config: { maxRequests: 10, perSeconds: 60 },
752
+ description: "10 signup attempts per minute per IP"
753
+ }
754
+ ])
755
+ })
756
+
757
+ inspect_route({ path: "/newsletter_signup" })
758
+
759
+ test_rest_endpoint({
760
+ method: "POST",
761
+ path: "/newsletter_signup",
762
+ body: { email: "test@example.com" }
763
+ })`,
764
+ notes: [
765
+ 'Use pre_auth for anonymous/public route protection because no user is available yet.',
766
+ 'Rate-limit configs use maxRequests and perSeconds.',
767
+ 'Inspect and test after creation so the final behavior is verified through the actual REST route.',
768
+ ],
769
+ },
770
+ {
771
+ name: 'Rate limit authenticated users',
772
+ code: `create_guard({
773
+ name: "Project create per-user limit",
774
+ path: "/projects",
775
+ methods: ["POST"],
776
+ position: "post_auth",
777
+ isEnabled: true,
778
+ description: "Authenticated users can create at most 3 projects per hour.",
779
+ rules: JSON.stringify([
780
+ {
781
+ type: "rate_limit_by_user",
782
+ config: { maxRequests: 3, perSeconds: 3600 }
783
+ }
784
+ ])
785
+ })`,
786
+ notes: [
787
+ 'Use post_auth for rate_limit_by_user because the server only has user id after auth and RoleGuard.',
788
+ 'This does not grant access; users still need route permissions or a public method to reach the route.',
789
+ 'Do not put rate_limit_by_user on pre_auth guards; the server drops that rule from pre-auth trees.',
790
+ ],
791
+ },
792
+ {
793
+ name: 'Restrict an admin-only route to office IPs',
794
+ code: `create_guard({
795
+ name: "Admin reports office allowlist",
796
+ path: "/admin/reports",
797
+ methods: ["GET", "POST"],
798
+ position: "pre_auth",
799
+ isEnabled: false,
800
+ description: "Only office network IPs can reach admin reports.",
801
+ rules: JSON.stringify([
802
+ {
803
+ type: "ip_whitelist",
804
+ config: { ips: ["203.0.113.10", "198.51.100.0/24"] }
805
+ }
806
+ ])
807
+ })`,
808
+ notes: [
809
+ 'Create risky allowlists disabled first, then inspect the saved guard before enabling it.',
810
+ 'IP list configs use ips; exact IPv4 addresses and IPv4 CIDR ranges are supported.',
811
+ 'An allowlist is an additional gate, not a replacement for route permissions.',
812
+ ],
813
+ },
739
814
  {
740
815
  name: 'Column rule for email format',
741
816
  code: `create_column_rule({
@@ -889,6 +964,7 @@ if (!membership.data[0]) @THROW403("Not a conversation member")
889
964
  @SOCKET.reply("chat:joined", { conversationId })`,
890
965
  notes: [
891
966
  'Join conversation rooms, not member-id rooms.',
967
+ 'conversationId is a request/room identifier; DB filters still use the relation property conversation.',
892
968
  'Check membership server-side; do not trust the client.',
893
969
  ],
894
970
  },
@@ -929,7 +1005,8 @@ if (message?.id) {
929
1005
 
930
1006
  return { ok: true, message }`,
931
1007
  notes: [
932
- 'Do not ask the client for senderId; use @USER.id.',
1008
+ 'Do not ask the client for senderId. The sender relation is derived from @USER.id.',
1009
+ 'conversationId is accepted only as the room/business identifier; persistence uses relation properties conversation and sender, not physical FK fields.',
933
1010
  'Event scripts should explicitly emit replies/broadcasts.',
934
1011
  ],
935
1012
  },
@@ -40,6 +40,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
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
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.',
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.',
@@ -82,6 +83,16 @@ export function buildMcpServerInstructions(apiBaseUrl) {
82
83
  '- **Right pattern:** **`create_route`** without `mainTableId` → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create). Handler/hook code must query explicit repos such as `$ctx.$repos.orders`; do not rely on `$repos.main` for custom routes.',
83
84
  '- **Handler contract:** `create_handler` takes `routeId`, `method` (or `methods` for batch), `sourceCode`, optional `scriptLanguage`, and optional `timeout`. Do **not** send `logic`, `name`, or `compiledCode`; backend CRUD rejects `logic` and `compiledCode` is generated by the server.',
84
85
  '',
86
+ '### Guards (request gates and rate limits)',
87
+ '- Guards are metadata-driven request gates stored in `enfyra_guard` and `enfyra_guard_rule`. Use them for IP allow/deny lists and coarse rate limits before writing custom pre-hooks. Use route permissions for authenticated access, field permissions for field access, and column rules for request body validation.',
88
+ '- Guard execution positions: `pre_auth` runs before JWT/auth and only has client IP + route/method context; use it for anonymous/IP/route-wide gates such as `rate_limit_by_ip`, `rate_limit_by_route`, `ip_whitelist`, and `ip_blacklist`. `post_auth` runs after auth/RoleGuard and has the authenticated user id; use it for `rate_limit_by_user` or rules scoped to specific users.',
89
+ '- Rule types: `rate_limit_by_ip`, `rate_limit_by_user`, `rate_limit_by_route`, `ip_whitelist`, and `ip_blacklist`. Rate-limit rule config is `{"maxRequests": number, "perSeconds": number}`. IP list config is `{"ips": ["203.0.113.10", "198.51.100.0/24"]}`; CIDR matching is IPv4-oriented.',
90
+ '- Root guards can attach to one route through `path` or `routeId`, or globally with `isGlobal: true`. `methods` is an array of HTTP method names; omit it to apply the guard to all methods on that route/global scope. Child guards inherit the root `position`; only root guard `position`, `route`, `methods`, and `isGlobal` determine where it runs.',
91
+ '- Guard `combinator` controls evaluation: `and` rejects on the first failing rule/child; `or` allows when any rule/child passes and rejects only if all fail. Lower `priority` runs first among sibling guards/rules.',
92
+ '- `create_guard` defaults `isEnabled` to `false` to avoid lockouts. For risky global or IP whitelist guards, create disabled first, inspect the saved guard/rules, then enable only after the rule config and route/method scope are confirmed.',
93
+ '- `create_guard` reloads guard cache best-effort after creation. After changing guard metadata, verify behavior with `inspect_route({ path })` and `test_rest_endpoint`; use `/admin/reload/guards` only when verification shows stale guard behavior.',
94
+ '- Do not use `rate_limit_by_user` or user-scoped rule `userIds` on `pre_auth` guards. Server guard cache drops `rate_limit_by_user` from pre-auth trees and ignores user scopes there because no user exists yet.',
95
+ '',
85
96
  '### After a new table is created',
86
97
  '- 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.',
87
98
  '- 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.',
@@ -96,7 +107,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
96
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.',
97
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.',
98
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.',
99
- '- 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`.',
100
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.',
101
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.',
102
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.',
@@ -150,7 +161,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
150
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.',
151
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.',
152
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.',
153
- '- 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.',
154
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.',
155
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.',
156
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.',
@@ -165,9 +176,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
165
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.',
166
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.',
167
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.',
168
- '- 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.',
169
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.',
170
- '- `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.',
171
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.',
172
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.',
173
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.',
@@ -2593,11 +2593,13 @@ server.tool(
2593
2593
  'create_guard',
2594
2594
  [
2595
2595
  'Create a metadata guard with optional rules for REST request gating.',
2596
- 'Root guards attach to route or global position. Rule configs: rate limits use {"maxRequests":number,"perSeconds":number}; IP lists use {"ips":["127.0.0.1"]}.',
2596
+ 'Root guards attach to one route by path/routeId or globally with isGlobal. pre_auth runs before JWT and only has IP/route context; post_auth runs after auth and can use user id.',
2597
+ 'Rule types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist. Rate limits use {"maxRequests":number,"perSeconds":number}; IP lists use {"ips":["127.0.0.1","10.0.0.0/24"]}.',
2598
+ 'Do not use rate_limit_by_user or userIds on pre_auth guards. Create risky global/IP whitelist guards disabled first, then inspect and test before enabling.',
2597
2599
  ].join(' '),
2598
2600
  {
2599
2601
  name: z.string().describe('Guard name'),
2600
- position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard'),
2602
+ position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard. pre_auth has only IP/route context; post_auth also has authenticated user id.'),
2601
2603
  routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id'),
2602
2604
  path: z.string().optional().describe('Optional route path'),
2603
2605
  methods: z.array(z.string()).optional().describe('Method names this guard applies to. Empty means all configured behavior for route/global.'),
@@ -2606,7 +2608,7 @@ server.tool(
2606
2608
  isGlobal: z.boolean().optional().default(false).describe('Apply globally instead of one route'),
2607
2609
  isEnabled: z.boolean().optional().default(false).describe('Enable immediately. Default false to avoid accidental lockout.'),
2608
2610
  description: z.string().optional().describe('Admin note'),
2609
- rules: z.string().optional().describe('Optional rules JSON array: [{type, config, priority?, isEnabled?, description?, userIds?}]'),
2611
+ rules: z.string().optional().describe('Optional rules JSON array: [{type, config, priority?, isEnabled?, description?, userIds?}]. Supported types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist.'),
2610
2612
  },
2611
2613
  async ({ name, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules }) => {
2612
2614
  let route = null;