@enfyra/mcp-server 0.0.123 → 0.1.1

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
@@ -1,6 +1,6 @@
1
1
  # Enfyra MCP Server
2
2
 
3
- Manage Enfyra instances from MCP-compatible coding tools such as **Codex**, **Claude Code**, **Cursor**, MCP Inspector, and other STDIO MCP hosts.
3
+ Manage Enfyra instances from MCP-compatible coding tools such as **Codex**, **Claude Code**, **Cursor**, **VS Code / GitHub Copilot**, **Google Antigravity**, MCP Inspector, and other STDIO MCP hosts.
4
4
 
5
5
  This package is the MCP bridge only. Assistant rules, schema behavior, dynamic script guidance, and examples are served through the MCP server itself from `src/lib/mcp-instructions.js`, `src/lib/mcp-examples.js`, and tool descriptions in `src/mcp-server-entry.mjs`.
6
6
 
@@ -12,7 +12,7 @@ From your project root:
12
12
  npx @enfyra/mcp-server config
13
13
  ```
14
14
 
15
- The config command writes project config for Codex, Claude Code, and Cursor. It preserves other MCP servers and replaces only the `enfyra` entry.
15
+ The config command writes project config for Codex, Claude Code, Cursor, VS Code / GitHub Copilot, and Google Antigravity. It preserves other MCP servers and replaces only the `enfyra` entry.
16
16
 
17
17
  Interactive setup asks for your Enfyra app/admin URL, then guides you to the token page when needed and asks for `ENFYRA_API_TOKEN`.
18
18
 
@@ -25,6 +25,8 @@ npx @enfyra/mcp-server config --yes \
25
25
  # One or more clients
26
26
  npx @enfyra/mcp-server config --codex
27
27
  npx @enfyra/mcp-server config --cursor --claude-code
28
+ npx @enfyra/mcp-server config --vscode
29
+ npx @enfyra/mcp-server config --antigravity
28
30
  ```
29
31
 
30
32
  Equivalent in this repo:
@@ -40,7 +42,9 @@ yarn mcp:config
40
42
  | Codex | `npx @enfyra/mcp-server config --codex` | `.codex/config.toml` |
41
43
  | Claude Code | `npx @enfyra/mcp-server config --claude-code` | `.mcp.json` |
42
44
  | Cursor | `npx @enfyra/mcp-server config --cursor` | `.cursor/mcp.json` |
43
- | MCP Inspector / other hosts | Paste the shared STDIO config below | Host-specific `mcpServers` config |
45
+ | VS Code / GitHub Copilot | `npx @enfyra/mcp-server config --vscode` | `.vscode/mcp.json` |
46
+ | Google Antigravity | `npx @enfyra/mcp-server config --antigravity` | `.agents/mcp_config.json` |
47
+ | MCP Inspector / other project-scoped hosts | Paste the shared STDIO config below | Host-specific project config |
44
48
 
45
49
  <details>
46
50
  <summary><strong>Codex setup</strong></summary>
@@ -108,10 +112,71 @@ Official reference: [Cursor MCP](https://cursor.com/docs/context/mcp).
108
112
 
109
113
  </details>
110
114
 
115
+ <details>
116
+ <summary><strong>VS Code / GitHub Copilot setup</strong></summary>
117
+
118
+ ```bash
119
+ npx @enfyra/mcp-server config --vscode
120
+ ```
121
+
122
+ VS Code workspace config is written to `.vscode/mcp.json`:
123
+
124
+ ```json
125
+ {
126
+ "servers": {
127
+ "enfyra": {
128
+ "type": "stdio",
129
+ "command": "npx",
130
+ "args": ["-y", "@enfyra/mcp-server"],
131
+ "env": {
132
+ "ENFYRA_API_URL": "http://localhost:3000/api",
133
+ "ENFYRA_API_TOKEN": "efy_pat_your-token"
134
+ }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ Use the VS Code command `MCP: List Servers` to inspect or start the server after setup. This is a workspace config, so it stays tied to the current project.
141
+
142
+ Official references: [VS Code MCP servers](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) and [VS Code MCP configuration](https://code.visualstudio.com/docs/agents/reference/mcp-configuration).
143
+
144
+ </details>
145
+
146
+ <details>
147
+ <summary><strong>Google Antigravity setup</strong></summary>
148
+
149
+ ```bash
150
+ npx @enfyra/mcp-server config --antigravity
151
+ ```
152
+
153
+ Antigravity project config is written to `.agents/mcp_config.json`:
154
+
155
+ ```json
156
+ {
157
+ "mcpServers": {
158
+ "enfyra": {
159
+ "command": "npx",
160
+ "args": ["-y", "@enfyra/mcp-server"],
161
+ "env": {
162
+ "ENFYRA_API_URL": "http://localhost:3000/api",
163
+ "ENFYRA_API_TOKEN": "efy_pat_your-token"
164
+ }
165
+ }
166
+ }
167
+ }
168
+ ```
169
+
170
+ Antigravity also documents a shared user config at `~/.gemini/config/mcp_config.json`; this helper intentionally writes the project-local `.agents/mcp_config.json` file so Enfyra URL and token stay scoped to the current workspace.
171
+
172
+ Official reference: [Antigravity MCP](https://antigravity.google/docs/mcp).
173
+
174
+ </details>
175
+
111
176
  <details>
112
177
  <summary><strong>Other MCP hosts and MCP Inspector</strong></summary>
113
178
 
114
- Use the shared STDIO config with any host that accepts an `mcpServers` JSON block:
179
+ Use the shared STDIO config with any project-scoped host that accepts an `mcpServers` JSON block:
115
180
 
116
181
  ```json
117
182
  {
@@ -145,11 +210,12 @@ npx @enfyra/mcp-server config [options]
145
210
  | `--app-url` | Set the Enfyra app/admin URL |
146
211
  | `--api-token`, `-t` | Set `ENFYRA_API_TOKEN` |
147
212
  | `--yes` | Non-interactive mode for CI/scripts |
148
- | `--global` | Write global/user config instead of project config |
149
213
  | `--reconfig` | Prompt for target clients again and replace the existing `enfyra` entry |
150
214
  | `--codex` | Write Codex config |
151
215
  | `--claude-code`, `--claude` | Write Claude Code config |
152
216
  | `--cursor` | Write Cursor config |
217
+ | `--vscode`, `--copilot` | Write VS Code / GitHub Copilot config |
218
+ | `--antigravity` | Write Google Antigravity config |
153
219
  | `-h`, `--help` | Show CLI help |
154
220
 
155
221
  Without a target flag, interactive mode asks which client to configure. Non-interactive mode defaults to all supported clients.
@@ -178,14 +244,18 @@ Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate
178
244
  - files and storage
179
245
  - Enfyra admin extensions
180
246
 
247
+ Use `get_enfyra_required_knowledge` before asking an LLM to save dynamic server code or Enfyra extension code. It returns short required contracts plus acknowledgement keys that code-writing tools verify before saving.
248
+
181
249
  ## Runtime Safety
182
250
 
183
251
  The MCP server includes safety guards for LLM callers:
184
252
 
185
253
  - Generic record mutations validate fields against live metadata.
254
+ - Code-writing tools require `get_enfyra_required_knowledge` acknowledgement before saving dynamic scripts or extension code. Discovery, validation, and preview tools remain available without the acknowledgement so agents can read and plan first.
186
255
  - Script-backed records validate `sourceCode` through `/admin/script/validate` before saving.
187
256
  - `validate_dynamic_script` checks handler, hook, flow, websocket, GraphQL, and bootstrap script source without saving.
188
257
  - `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
258
+ - Dynamic script guidance distinguishes secure repositories (`@REPOS.main`, `@REPOS.secure.<table>`) from trusted internal repositories (`@REPOS.<table>`), and tells agents not to return raw trusted records to users.
189
259
  - `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
190
260
  - JSON responses include `compressionStats` with estimated token savings. Arrays of objects are converted to columnar form only when the compact shape is smaller than raw JSON.
191
261
  - Relation tools reject physical FK/junction names and resolve table ids from exact table names or aliases before schema mutation.
@@ -229,7 +299,7 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
229
299
 
230
300
  ## Tool Summary
231
301
 
232
- The MCP server exposes tools for metadata discovery, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, and `get_enfyra_api_context`.
302
+ The MCP server exposes tools for metadata discovery, required knowledge, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, `get_enfyra_api_context`, and `get_enfyra_required_knowledge`.
233
303
 
234
304
  Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods` and `private_route_methods` for that access boundary.
235
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.123",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -11,15 +11,17 @@ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
11
11
 
12
12
  Usage:
13
13
  npx @enfyra/mcp-server Start the MCP stdio server
14
- npx @enfyra/mcp-server config [flags] Write local MCP host config
14
+ npx @enfyra/mcp-server config [flags] Write project-local MCP host config
15
15
 
16
16
  Common config flags:
17
- --codex Write ~/.codex/config.toml
17
+ --codex Write ./.codex/config.toml
18
18
  --claude-code Write ./.mcp.json
19
19
  --cursor Write ./.cursor/mcp.json
20
+ --vscode Write ./.vscode/mcp.json
21
+ --antigravity Write ./.agents/mcp_config.json
20
22
  --reconfig Prompt for host and credentials again, replacing the enfyra entry
21
23
  --yes Non-interactive
22
- -a, --api-url ENFYRA_API_URL
24
+ --app-url ENFYRA_APP_URL
23
25
  -t, --api-token ENFYRA_API_TOKEN
24
26
  -h, --help Show config help
25
27
 
@@ -3,7 +3,6 @@ import { createInterface } from 'node:readline/promises';
3
3
  import { emitKeypressEvents } from 'node:readline';
4
4
  import { stdin as input, stdout as output, cwd } from 'node:process';
5
5
  import { dirname, join } from 'node:path';
6
- import { homedir } from 'node:os';
7
6
 
8
7
  const SERVER_KEY = 'enfyra';
9
8
  const forceColor = process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== '0';
@@ -36,6 +35,16 @@ const clients = {
36
35
  path: './.cursor/mcp.json',
37
36
  color: style.cyan,
38
37
  },
38
+ vscode: {
39
+ label: 'VS Code / Copilot',
40
+ path: './.vscode/mcp.json',
41
+ color: style.blue,
42
+ },
43
+ antigravity: {
44
+ label: 'Antigravity',
45
+ path: './.agents/mcp_config.json',
46
+ color: style.yellow,
47
+ },
39
48
  };
40
49
 
41
50
  function statusIcon(kind) {
@@ -55,7 +64,7 @@ function exitCancelled() {
55
64
 
56
65
  function printHelp() {
57
66
  console.log(`${style.bold('Enfyra MCP config')}
58
- ${style.dim('Write local MCP client config for Enfyra.')}
67
+ ${style.dim('Write project-local MCP client config for Enfyra.')}
59
68
 
60
69
  ${style.bold('Usage')}
61
70
  npx @enfyra/mcp-server config [options]
@@ -64,12 +73,12 @@ ${style.bold('Supported clients')}
64
73
  Codex ./.codex/config.toml
65
74
  Claude Code ./.mcp.json
66
75
  Cursor ./.cursor/mcp.json
67
- Other MCP hosts can use the shared stdio JSON from the README.
76
+ VS Code ./.vscode/mcp.json
77
+ Antigravity ./.agents/mcp_config.json
68
78
 
69
79
  ${style.bold('Options')}
70
80
  --app-url <url> Enfyra app/admin URL, for example https://demo.enfyra.io
71
81
  --api-token, -t <secret> ENFYRA_API_TOKEN
72
- --global Write global/user config for selected clients instead of project config
73
82
  --reconfig Always choose target again in interactive mode and replace the old enfyra config for that target
74
83
  --yes Non-interactive: no prompts (CI / scripts); use CLI, env, existing file, then defaults
75
84
 
@@ -79,20 +88,23 @@ ${style.bold('Client selection')}
79
88
  --claude-code, --claude, --claude-only Only ./.mcp.json (Claude Code project scope)
80
89
  --cursor, --cursor-only Only ./.cursor/mcp.json (Cursor)
81
90
  --codex, --codex-only Only ./.codex/config.toml (Codex project scope)
91
+ --vscode, --copilot, --vscode-only Only ./.vscode/mcp.json (VS Code / Copilot)
92
+ --antigravity, --antigravity-only Only ./.agents/mcp_config.json (Antigravity)
82
93
  Passing multiple target flags writes each selected target.
83
94
 
84
95
  -h, --help Show this help
85
96
 
86
97
  ${style.bold('Interactive mode')}
87
- Choose Codex, Claude Code, Cursor, or all clients; then enter ENFYRA_APP_URL and ENFYRA_API_TOKEN.
98
+ Choose Codex, Claude Code, Cursor, VS Code, Antigravity, or all clients; then enter ENFYRA_APP_URL and ENFYRA_API_TOKEN.
88
99
  Existing Enfyra config and environment variables are used as defaults. Re-run anytime to update.
89
100
 
90
101
  ${style.bold('Examples')}
91
102
  npx @enfyra/mcp-server config
92
103
  npx @enfyra/mcp-server config --yes
93
104
  npx @enfyra/mcp-server config --codex --cursor
105
+ npx @enfyra/mcp-server config --vscode
106
+ npx @enfyra/mcp-server config --antigravity
94
107
  npx @enfyra/mcp-server config --claude-code
95
- npx @enfyra/mcp-server config --global --codex
96
108
  npx @enfyra/mcp-server config --reconfig
97
109
  npx @enfyra/mcp-server config --app-url http://localhost:3000 -t 'efy_pat_...'
98
110
  ENFYRA_APP_URL=https://demo.enfyra.io ENFYRA_API_TOKEN=efy_pat_... npx @enfyra/mcp-server config --yes
@@ -109,11 +121,14 @@ function parseArgs(argv) {
109
121
  help: false,
110
122
  yes: false,
111
123
  reconfig: false,
112
- global: false,
124
+ vscode: true,
125
+ antigravity: true,
113
126
  };
114
127
  let pickClaude = false;
115
128
  let pickCursor = false;
116
129
  let pickCodex = false;
130
+ let pickVscode = false;
131
+ let pickAntigravity = false;
117
132
  for (let i = 0; i < argv.length; i += 1) {
118
133
  const a = argv[i];
119
134
  const next = () => {
@@ -126,7 +141,6 @@ function parseArgs(argv) {
126
141
  else if (a === 'help') out.help = true;
127
142
  else if (a === '--yes') out.yes = true;
128
143
  else if (a === '--reconfig') out.reconfig = true;
129
- else if (a === '--global') out.global = true;
130
144
  else if (a === '--app-url') out.appUrl = next();
131
145
  else if (a === '--api-url' || a === '-a') {
132
146
  throw new Error(`${a} is no longer supported for setup; use --app-url instead`);
@@ -138,13 +152,17 @@ function parseArgs(argv) {
138
152
  else if (a === '--claude-only' || a === '--claude-code' || a === '--claude') pickClaude = true;
139
153
  else if (a === '--cursor-only' || a === '--cursor') pickCursor = true;
140
154
  else if (a === '--codex-only' || a === '--codex') pickCodex = true;
155
+ else if (a === '--vscode-only' || a === '--vscode' || a === '--copilot') pickVscode = true;
156
+ else if (a === '--antigravity-only' || a === '--antigravity') pickAntigravity = true;
141
157
  else throw new Error(`Unknown argument: ${a}`);
142
158
  }
143
- out.targetExplicit = pickClaude || pickCursor || pickCodex;
159
+ out.targetExplicit = pickClaude || pickCursor || pickCodex || pickVscode || pickAntigravity;
144
160
  if (out.targetExplicit) {
145
161
  out.claude = pickClaude;
146
162
  out.cursor = pickCursor;
147
163
  out.codex = pickCodex;
164
+ out.vscode = pickVscode;
165
+ out.antigravity = pickAntigravity;
148
166
  }
149
167
  return out;
150
168
  }
@@ -193,9 +211,7 @@ async function mergeMcpFile(absPath, serverEntry) {
193
211
  try {
194
212
  const raw = await readFile(absPath, 'utf8');
195
213
  const parsed = JSON.parse(raw);
196
- if (parsed && typeof parsed === 'object' && parsed.mcpServers && typeof parsed.mcpServers === 'object') {
197
- data.mcpServers = { ...parsed.mcpServers };
198
- } else if (parsed && typeof parsed === 'object') {
214
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
199
215
  data = { ...parsed, mcpServers: parsed.mcpServers && typeof parsed.mcpServers === 'object' ? { ...parsed.mcpServers } : {} };
200
216
  }
201
217
  } catch (e) {
@@ -207,6 +223,31 @@ async function mergeMcpFile(absPath, serverEntry) {
207
223
  await writeFile(absPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
208
224
  }
209
225
 
226
+ async function mergeVscodeMcpFile(absPath, serverEntry) {
227
+ let data = { servers: {} };
228
+ try {
229
+ const raw = await readFile(absPath, 'utf8');
230
+ const parsed = JSON.parse(raw);
231
+ if (parsed && typeof parsed === 'object') {
232
+ data = {
233
+ ...parsed,
234
+ servers: parsed.servers && typeof parsed.servers === 'object' ? { ...parsed.servers } : {},
235
+ };
236
+ }
237
+ } catch (e) {
238
+ if (e.code !== 'ENOENT') throw e;
239
+ }
240
+ data.servers = {
241
+ ...data.servers,
242
+ [SERVER_KEY]: {
243
+ type: 'stdio',
244
+ ...serverEntry,
245
+ },
246
+ };
247
+ await mkdir(dirname(absPath), { recursive: true });
248
+ await writeFile(absPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
249
+ }
250
+
210
251
  function tomlString(value) {
211
252
  return JSON.stringify(String(value ?? ''));
212
253
  }
@@ -285,50 +326,69 @@ async function readCodexEnfyraEnv(absPath) {
285
326
  return null;
286
327
  }
287
328
 
288
- function getCodexConfigPath(root, globalScope) {
289
- return globalScope ? join(homedir(), '.codex', 'config.toml') : join(root, '.codex', 'config.toml');
329
+ function getCodexConfigPath(root) {
330
+ return join(root, '.codex', 'config.toml');
331
+ }
332
+
333
+ function getClaudeConfigPath(root) {
334
+ return join(root, '.mcp.json');
335
+ }
336
+
337
+ function getCursorConfigPath(root) {
338
+ return join(root, '.cursor', 'mcp.json');
290
339
  }
291
340
 
292
- function getClaudeConfigPath(root, globalScope) {
293
- return globalScope ? join(homedir(), '.mcp.json') : join(root, '.mcp.json');
341
+ function getVscodeConfigPath(root) {
342
+ return join(root, '.vscode', 'mcp.json');
294
343
  }
295
344
 
296
- function getCursorConfigPath(root, globalScope) {
297
- return globalScope ? join(homedir(), '.cursor', 'mcp.json') : join(root, '.cursor', 'mcp.json');
345
+ function getAntigravityConfigPath(root) {
346
+ return join(root, '.agents', 'mcp_config.json');
298
347
  }
299
348
 
300
- function getClientPath(client, root, globalScope) {
301
- if (client === 'claude') return getClaudeConfigPath(root, globalScope);
302
- if (client === 'cursor') return getCursorConfigPath(root, globalScope);
303
- if (client === 'codex') return getCodexConfigPath(root, globalScope);
349
+ function getClientPath(client, root) {
350
+ if (client === 'claude') return getClaudeConfigPath(root);
351
+ if (client === 'cursor') return getCursorConfigPath(root);
352
+ if (client === 'codex') return getCodexConfigPath(root);
353
+ if (client === 'vscode') return getVscodeConfigPath(root);
354
+ if (client === 'antigravity') return getAntigravityConfigPath(root);
304
355
  throw new Error(`Unknown MCP client: ${client}`);
305
356
  }
306
357
 
307
- async function loadExistingEnfyraEnv(root, readClaude, readCursor, readCodex, globalScope) {
358
+ async function readMcpServerEnv(absPath, serverRootKey) {
359
+ try {
360
+ const raw = await readFile(absPath, 'utf8');
361
+ const j = JSON.parse(raw);
362
+ const e = j?.[serverRootKey]?.[SERVER_KEY]?.env;
363
+ if (e && typeof e === 'object' && (e.ENFYRA_API_URL || e.ENFYRA_API_TOKEN)) {
364
+ return {
365
+ apiUrl: typeof e.ENFYRA_API_URL === 'string' ? e.ENFYRA_API_URL : '',
366
+ apiToken: typeof e.ENFYRA_API_TOKEN === 'string' ? e.ENFYRA_API_TOKEN : '',
367
+ };
368
+ }
369
+ } catch {
370
+ /* */
371
+ }
372
+ return null;
373
+ }
374
+
375
+ async function loadExistingEnfyraEnv(root, readClaude, readCursor, readCodex, readVscode, readAntigravity) {
308
376
  const paths = [];
309
- if (readClaude) paths.push(getClaudeConfigPath(root, globalScope));
310
- if (readCursor) paths.push(getCursorConfigPath(root, globalScope));
311
- if (!globalScope && !readClaude && readCursor) paths.push(join(root, '.mcp.json'));
377
+ if (readClaude) paths.push({ path: getClaudeConfigPath(root), rootKey: 'mcpServers' });
378
+ if (readCursor) paths.push({ path: getCursorConfigPath(root), rootKey: 'mcpServers' });
379
+ if (!readClaude && readCursor) paths.push({ path: getClaudeConfigPath(root), rootKey: 'mcpServers' });
380
+ if (readVscode) paths.push({ path: getVscodeConfigPath(root), rootKey: 'servers' });
381
+ if (readAntigravity) paths.push({ path: getAntigravityConfigPath(root), rootKey: 'mcpServers' });
312
382
  const seen = new Set();
313
- for (const p of paths) {
314
- if (seen.has(p)) continue;
315
- seen.add(p);
316
- try {
317
- const raw = await readFile(p, 'utf8');
318
- const j = JSON.parse(raw);
319
- const e = j?.mcpServers?.[SERVER_KEY]?.env;
320
- if (e && typeof e === 'object' && (e.ENFYRA_API_URL || e.ENFYRA_API_TOKEN)) {
321
- return {
322
- apiUrl: typeof e.ENFYRA_API_URL === 'string' ? e.ENFYRA_API_URL : '',
323
- apiToken: typeof e.ENFYRA_API_TOKEN === 'string' ? e.ENFYRA_API_TOKEN : '',
324
- };
325
- }
326
- } catch {
327
- /* */
328
- }
383
+ for (const entry of paths) {
384
+ const key = `${entry.rootKey}:${entry.path}`;
385
+ if (seen.has(key)) continue;
386
+ seen.add(key);
387
+ const env = await readMcpServerEnv(entry.path, entry.rootKey);
388
+ if (env) return env;
329
389
  }
330
390
  if (readCodex) {
331
- const codex = await readCodexEnfyraEnv(getCodexConfigPath(root, globalScope));
391
+ const codex = await readCodexEnfyraEnv(getCodexConfigPath(root));
332
392
  if (codex) return codex;
333
393
  }
334
394
  return { apiUrl: '', apiToken: '' };
@@ -338,23 +398,31 @@ async function promptTargetChoice() {
338
398
  const choices = [
339
399
  {
340
400
  client: 'codex',
341
- value: { claude: false, cursor: false, codex: true },
401
+ value: { claude: false, cursor: false, codex: true, vscode: false, antigravity: false },
342
402
  },
343
403
  {
344
404
  client: 'claude',
345
- value: { claude: true, cursor: false, codex: false },
405
+ value: { claude: true, cursor: false, codex: false, vscode: false, antigravity: false },
346
406
  },
347
407
  {
348
408
  client: 'cursor',
349
- value: { claude: false, cursor: true, codex: false },
409
+ value: { claude: false, cursor: true, codex: false, vscode: false, antigravity: false },
410
+ },
411
+ {
412
+ client: 'vscode',
413
+ value: { claude: false, cursor: false, codex: false, vscode: true, antigravity: false },
414
+ },
415
+ {
416
+ client: 'antigravity',
417
+ value: { claude: false, cursor: false, codex: false, vscode: false, antigravity: true },
350
418
  },
351
419
  {
352
420
  client: 'all',
353
- value: { claude: true, cursor: true, codex: true },
421
+ value: { claude: true, cursor: true, codex: true, vscode: true, antigravity: true },
354
422
  },
355
423
  ];
356
424
  if (input.setRawMode && output.isTTY) {
357
- return promptTargetSelect(choices, 3);
425
+ return promptTargetSelect(choices, 5);
358
426
  }
359
427
 
360
428
  const rl = createInterface({ input, output });
@@ -363,23 +431,31 @@ async function promptTargetChoice() {
363
431
  + ' [1] Codex ./.codex/config.toml\n'
364
432
  + ' [2] Claude Code ./.mcp.json\n'
365
433
  + ' [3] Cursor ./.cursor/mcp.json\n'
366
- + ' [4] All [default]\n'
367
- + 'Choice [4]: ',
434
+ + ' [4] VS Code ./.vscode/mcp.json\n'
435
+ + ' [5] Antigravity ./.agents/mcp_config.json\n'
436
+ + ' [6] All [default]\n'
437
+ + 'Choice [6]: ',
368
438
  )).trim().toLowerCase();
369
439
  await rl.close();
370
- if (line === '' || line === '4' || line === 'all' || line === 'a') {
371
- return { claude: true, cursor: true, codex: true };
440
+ if (line === '' || line === '6' || line === 'all' || line === 'a') {
441
+ return { claude: true, cursor: true, codex: true, vscode: true, antigravity: true };
372
442
  }
373
443
  if (line === '1' || line === 'codex' || line === 'x') {
374
- return { claude: false, cursor: false, codex: true };
444
+ return { claude: false, cursor: false, codex: true, vscode: false, antigravity: false };
375
445
  }
376
446
  if (line === '2' || line === 'claude' || line === 'claude-code') {
377
- return { claude: true, cursor: false, codex: false };
447
+ return { claude: true, cursor: false, codex: false, vscode: false, antigravity: false };
378
448
  }
379
449
  if (line === '3' || line === 'cursor' || line === 'u') {
380
- return { claude: false, cursor: true, codex: false };
450
+ return { claude: false, cursor: true, codex: false, vscode: false, antigravity: false };
451
+ }
452
+ if (line === '4' || line === 'vscode' || line === 'vs-code' || line === 'copilot') {
453
+ return { claude: false, cursor: false, codex: false, vscode: true, antigravity: false };
454
+ }
455
+ if (line === '5' || line === 'antigravity') {
456
+ return { claude: false, cursor: false, codex: false, vscode: false, antigravity: true };
381
457
  }
382
- return { claude: true, cursor: true, codex: true };
458
+ return { claude: true, cursor: true, codex: true, vscode: true, antigravity: true };
383
459
  }
384
460
 
385
461
  async function promptTargetSelect(choices, initialIndex = 0) {
@@ -392,7 +468,7 @@ async function promptTargetSelect(choices, initialIndex = 0) {
392
468
  if (choice.client === 'all') {
393
469
  const label = active ? style.bold(style.underline('All supported clients')) : 'All supported clients';
394
470
  const paddedLabel = label + ' '.repeat(22 - 'All supported clients'.length);
395
- const hint = active ? style.cyan('Codex + Claude Code + Cursor') : style.dim('Codex + Claude Code + Cursor');
471
+ const hint = active ? style.cyan('Codex + Claude Code + Cursor + VS Code + Antigravity') : style.dim('Codex + Claude Code + Cursor + VS Code + Antigravity');
396
472
  return `${accent} ${indicator} ${paddedLabel} ${hint}`;
397
473
  }
398
474
 
@@ -545,14 +621,18 @@ export async function runLocalConfig(argv) {
545
621
  let writeClaude = opts.claude;
546
622
  let writeCursor = opts.cursor;
547
623
  let writeCodex = opts.codex;
624
+ let writeVscode = opts.vscode;
625
+ let writeAntigravity = opts.antigravity;
548
626
  if (usePrompt && (!opts.targetExplicit || opts.reconfig)) {
549
627
  const t = await promptTargetChoice();
550
628
  writeClaude = t.claude;
551
629
  writeCursor = t.cursor;
552
630
  writeCodex = t.codex;
631
+ writeVscode = t.vscode;
632
+ writeAntigravity = t.antigravity;
553
633
  }
554
634
 
555
- const existing = await loadExistingEnfyraEnv(root, writeClaude, writeCursor, writeCodex, opts.global);
635
+ const existing = await loadExistingEnfyraEnv(root, writeClaude, writeCursor, writeCodex, writeVscode, writeAntigravity);
556
636
 
557
637
  let apiUrl;
558
638
  let apiToken;
@@ -578,23 +658,32 @@ export async function runLocalConfig(argv) {
578
658
  const written = [];
579
659
 
580
660
  if (writeCodex) {
581
- const p = getClientPath('codex', root, opts.global);
661
+ const p = getClientPath('codex', root);
582
662
  await mergeCodexConfig(p, apiUrl, apiToken);
583
663
  written.push({ client: 'codex', path: p });
584
664
  }
585
665
  if (writeClaude) {
586
- const p = getClientPath('claude', root, opts.global);
666
+ const p = getClientPath('claude', root);
587
667
  await mergeMcpFile(p, serverEntry);
588
668
  written.push({ client: 'claude', path: p });
589
669
  }
590
670
  if (writeCursor) {
591
- const p = getClientPath('cursor', root, opts.global);
671
+ const p = getClientPath('cursor', root);
592
672
  await mergeMcpFile(p, serverEntry);
593
673
  written.push({ client: 'cursor', path: p });
594
674
  }
675
+ if (writeVscode) {
676
+ const p = getClientPath('vscode', root);
677
+ await mergeVscodeMcpFile(p, serverEntry);
678
+ written.push({ client: 'vscode', path: p });
679
+ }
680
+ if (writeAntigravity) {
681
+ const p = getClientPath('antigravity', root);
682
+ await mergeMcpFile(p, serverEntry);
683
+ written.push({ client: 'antigravity', path: p });
684
+ }
595
685
 
596
- const scopeLabel = opts.global ? 'global/user' : 'project';
597
- console.log(`${statusIcon('success')} ${style.bold(style.green('Enfyra MCP config updated'))} ${style.dim(`(${scopeLabel})`)}\n`);
686
+ console.log(`${statusIcon('success')} ${style.bold(style.green('Enfyra MCP config updated'))} ${style.dim('(project)')}\n`);
598
687
  for (const entry of written) {
599
688
  const meta = clients[entry.client];
600
689
  console.log(` ${style.cyan('•')} ${style.bold(meta.color(meta.label))}`);
@@ -612,6 +701,12 @@ export async function runLocalConfig(argv) {
612
701
  if (selectedClients.has('cursor')) {
613
702
  console.log(' - Cursor: restart Cursor or reload MCP, then confirm the server under Settings -> MCP.');
614
703
  }
704
+ if (selectedClients.has('vscode')) {
705
+ console.log(' - VS Code / Copilot: run MCP: List Servers or reload the workspace, then start the Enfyra server if prompted.');
706
+ }
707
+ if (selectedClients.has('antigravity')) {
708
+ console.log(' - Antigravity: reopen the workspace or reload MCP servers so ./.agents/mcp_config.json is picked up.');
709
+ }
615
710
  console.log(' - Re-run this command anytime to update the same Enfyra entries.');
616
711
  if (!apiToken) {
617
712
  console.log(`\n${statusIcon('warn')} ${style.yellow('ENFYRA_API_TOKEN is empty; tools will not authenticate until it is set.')}`);
@@ -894,7 +894,8 @@ query_table({
894
894
  notes: [
895
895
  'Use _max(relation.field) for latest-child ordering, _min(relation.field) for earliest-child ordering, and _count(relation) for child-count ordering.',
896
896
  'Aggregate sort helpers only work on direct one-to-many or many-to-many list relations.',
897
- 'The aggregate field must be a real non-encrypted scalar field on the related table.',
897
+ 'The aggregate field must be a real published, non-encrypted scalar field on the related table for user-facing APIs.',
898
+ 'Do not use _max, _min, or _count on private relations or unpublished fields unless the endpoint intentionally exposes that fact.',
898
899
  'Do not use raw sort=-messages.createdAt for parent ordering; it is ambiguous and rejected.',
899
900
  'deep.messages.sort only orders the loaded message rows inside each ticket, so keep parent sort and child pagination as separate concerns.',
900
901
  ],
@@ -943,6 +944,7 @@ const found = await #integrations.find({
943
944
  routeId: "<route_id>",
944
945
  method: "POST",
945
946
  scriptLanguage: "javascript",
947
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
946
948
  sourceCode: \`const email = @BODY.email
947
949
  if (!email) @THROW400("Email is required")
948
950
 
@@ -950,6 +952,7 @@ return { ok: true, email }\`
950
952
  })`,
951
953
  notes: [
952
954
  'Use sourceCode, not logic. The server generates compiledCode.',
955
+ 'Call get_enfyra_required_knowledge before saving dynamic code and pass dynamicCodeAckKey as knowledgeAckKey.',
953
956
  'Use method for one handler, or methods only when the same sourceCode should be saved for multiple methods.',
954
957
  'Do not pass name to enfyra_route_handler; one handler is identified by route + method.',
955
958
  ],
@@ -1030,6 +1033,7 @@ const scope = {
1030
1033
  name: "strip_email_verification_fields",
1031
1034
  methods: ["PATCH"],
1032
1035
  priority: -10,
1036
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
1033
1037
  code: \`delete @BODY.emailVerifiedAt
1034
1038
  delete @BODY.emailVerificationStatus
1035
1039
  delete @BODY.emailVerificationSentAt\`
@@ -1047,6 +1051,7 @@ delete @BODY.emailVerificationSentAt\`
1047
1051
  name: "shape_display_title",
1048
1052
  methods: ["GET"],
1049
1053
  priority: 0,
1054
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
1050
1055
  code: \`if (@ERROR) {
1051
1056
  @LOGS("Request failed", @ERROR.message)
1052
1057
  return
@@ -1320,7 +1325,7 @@ const socket = io("/chat", {
1320
1325
  code: `const conversationId = @BODY.conversationId
1321
1326
  if (!conversationId) @THROW400("conversationId is required")
1322
1327
 
1323
- const membership = await @REPOS.chat_conversation_member.find({
1328
+ const membership = await @REPOS.secure.chat_conversation_member.find({
1324
1329
  filter: {
1325
1330
  conversation: { id: { _eq: conversationId } },
1326
1331
  member: { id: { _eq: @USER.id } }
@@ -1336,6 +1341,7 @@ if (!membership.data[0]) @THROW403("Not a conversation member")
1336
1341
  'Join conversation rooms, not member-id rooms.',
1337
1342
  'conversationId is a request/room identifier; DB filters still use the relation property conversation.',
1338
1343
  'Check membership server-side; do not trust the client.',
1344
+ 'Use @REPOS.secure.<table> for explicit table access in user-facing websocket scripts.',
1339
1345
  ],
1340
1346
  },
1341
1347
  {
@@ -1343,7 +1349,7 @@ if (!membership.data[0]) @THROW403("Not a conversation member")
1343
1349
  code: `const { conversationId, text, clientId } = @BODY
1344
1350
  if (!conversationId || !text) @THROW400("conversationId and text are required")
1345
1351
 
1346
- const membership = await @REPOS.chat_conversation_member.find({
1352
+ const membership = await @REPOS.secure.chat_conversation_member.find({
1347
1353
  filter: {
1348
1354
  conversation: { id: { _eq: conversationId } },
1349
1355
  member: { id: { _eq: @USER.id } }
@@ -1352,7 +1358,7 @@ const membership = await @REPOS.chat_conversation_member.find({
1352
1358
  })
1353
1359
  if (!membership.data[0]) @THROW403("Not a conversation member")
1354
1360
 
1355
- const created = await @REPOS.chat_message.create({
1361
+ const created = await @REPOS.secure.chat_message.create({
1356
1362
  data: {
1357
1363
  conversation: { id: conversationId },
1358
1364
  sender: { id: @USER.id },
@@ -1363,7 +1369,7 @@ const created = await @REPOS.chat_message.create({
1363
1369
 
1364
1370
  const message = created.data?.[0] ?? null
1365
1371
  if (message?.id) {
1366
- await @REPOS.chat_conversation.update({
1372
+ await @REPOS.secure.chat_conversation.update({
1367
1373
  id: conversationId,
1368
1374
  data: { lastMessage: { id: message.id }, updatedAt: message.createdAt || new Date().toISOString() }
1369
1375
  })
@@ -1378,6 +1384,7 @@ return { ok: true, message }`,
1378
1384
  'Do not ask the client for senderId. The sender relation is derived from @USER.id.',
1379
1385
  'conversationId is accepted only as the room/business identifier; persistence uses relation properties conversation and sender, not physical FK fields.',
1380
1386
  'Event scripts should explicitly emit replies/broadcasts.',
1387
+ 'Use @REPOS.secure.<table> for explicit table access in user-facing websocket scripts; trusted @REPOS.<table> is only for internal/admin logic that will sanitize output.',
1381
1388
  ],
1382
1389
  },
1383
1390
  ],
@@ -1551,7 +1558,8 @@ ensure_page_extension({
1551
1558
  description: "Reports dashboard",
1552
1559
  menuId: "<created-menu-id>",
1553
1560
  code: "<template><section class=\\"min-h-full w-full space-y-4\\"><div class=\\"grid gap-4 md:grid-cols-2 xl:grid-cols-3\\"><article class=\\"eapp-surface-card p-4\\"><div class=\\"flex items-start justify-between gap-3\\"><div><p class=\\"text-sm font-medium eapp-text-tertiary\\">Total</p><p class=\\"mt-2 text-2xl font-semibold eapp-text-primary\\">0</p></div><span class=\\"eapp-primary-soft eapp-icon-tile\\"><span class=\\"eapp-primary-text\\">◆</span></span></div><div class=\\"mt-3 h-1.5 overflow-hidden eapp-radius-pill eapp-surface-muted\\"><div class=\\"eapp-primary-solid h-full w-1/2\\"></div></div></article><article class=\\"eapp-primary-surface eapp-radius-panel border p-4\\"><p class=\\"text-sm font-semibold eapp-text-primary\\">Selected report</p><p class=\\"mt-1 text-sm eapp-text-tertiary\\">Only selected/current identity blocks use identity surface.</p></article></div></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); const { register: registerHeaderActions } = useHeaderActionRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'none', variant: 'minimal' }); registerHeaderActions([{ id: 'refresh-reports', label: 'Refresh', icon: 'lucide:refresh-cw', color: 'neutral', variant: 'outline', onClick: () => {}, order: 80 }])</script>",
1554
- isEnabled: true
1561
+ isEnabled: true,
1562
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1555
1563
  })`,
1556
1564
  notes: [
1557
1565
  'Menu provides navigation; extension provides content.',
@@ -1559,6 +1567,7 @@ ensure_page_extension({
1559
1567
  'Sensitive admin menus should include a permission condition at creation time.',
1560
1568
  'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1561
1569
  'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI.',
1570
+ 'Call get_enfyra_required_knowledge before saving extension code and pass extensionAckKey as extensionKnowledgeAckKey.',
1562
1571
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1563
1572
  'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
1564
1573
  'Do not put ordinary KPI cards in PageHeader.stats; render metrics in the extension body.',
@@ -1623,7 +1632,8 @@ ensure_widget_extension({
1623
1632
  name: "ReportStatusWidget",
1624
1633
  description: "Report status summary cards",
1625
1634
  code: reportStatusWidgetCode,
1626
- isEnabled: true
1635
+ isEnabled: true,
1636
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1627
1637
  })
1628
1638
 
1629
1639
  // Read the created widget record id, then embed it from the page extension.
@@ -1631,7 +1641,8 @@ ensure_page_extension({
1631
1641
  name: "ReportsPage",
1632
1642
  menuId: "<reports-menu-id>",
1633
1643
  code: "<template><section class=\\"min-h-full w-full space-y-4\\"><Widget :id=\\"<report-status-widget-id>\\" :total=\\"totalReports\\" :rows=\\"reportRows\\" :open-details=\\"openReportDetails\\" @refresh=\\"refresh\\" /><Widget :id=\\"<report-table-widget-id>\\" :rows=\\"reportRows\\" @refresh=\\"refresh\\" /></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'none', variant: 'minimal' }); const totalReports = ref(0); const reportRows = ref([]); function refresh() {} function openReportDetails(row) { navigateTo('/data/report?filter=' + encodeURIComponent(JSON.stringify({ id: { _eq: row.id } }))) }</script>",
1634
- isEnabled: true
1644
+ isEnabled: true,
1645
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1635
1646
  })`,
1636
1647
  notes: [
1637
1648
  'Use widgets for bulky or reusable sections such as operation panels, timelines, tables, sidebars, and status cards.',
@@ -1707,7 +1718,8 @@ ensure_global_extension({
1707
1718
  name: "NotificationBellGlobal",
1708
1719
  description: "Registers the app-wide notification bell in the account panel",
1709
1720
  code: notificationBellCode,
1710
- isEnabled: true
1721
+ isEnabled: true,
1722
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1711
1723
  })`,
1712
1724
  notes: [
1713
1725
  'Global extensions are mounted invisibly by Enfyra admin UI during layout init; do not create a menu and do not embed them with Widget.',
@@ -2030,6 +2042,7 @@ onMounted(() => Promise.all([flowStats.execute(), orderStats.execute()]))
2030
2042
  'Aggregate keys must be real fields or relations.',
2031
2043
  'Read results from response.meta.aggregate.',
2032
2044
  'Use top-level filter for time windows and cross-field conditions.',
2045
+ 'Only aggregate fields and relations that the dashboard is allowed to expose; aggregate values can reveal hidden data even when rows omit that field.',
2033
2046
  'sum/avg require numeric fields; amount_usd must be a real float/numeric SQL column, not metadata-only float over a varchar physical column.',
2034
2047
  ],
2035
2048
  },
@@ -27,25 +27,30 @@ export function buildMcpServerInstructions(apiBaseUrl) {
27
27
  '- For a quick target/base sanity check, call `get_enfyra_api_context`; do not call broad discovery just to confirm which instance this MCP is connected to.',
28
28
  '- Discover before deciding. For architecture/capability questions call `discover_enfyra_system`; for DB/pk/runtime/cache context call `discover_runtime_context`; for filters/deep/sort/relation query shape call `discover_query_capabilities`. Run broad discovery tools sequentially, not in parallel.',
29
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
- '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category. Before extension UI work, call `get_extension_theme_contract` and follow the app-shell/theme/security contract. For the exact class name or Nuxt UI color mapping, call `get_theme_class_reference`; this is the authoritative theme & color contract and must be followed exactly - never use raw CSS variables, Tailwind palette accents, or concrete palettes like color="violet".',
30
+ '- Load examples only when needed. Use `get_enfyra_examples` by category. Before extension UI, call `get_extension_theme_contract`; call `get_theme_class_reference` for exact eapp/Nuxt UI theme classes.',
31
31
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
+ '- Before writing dynamic server or extension code, call `get_enfyra_required_knowledge` and pass the matching acknowledgement key into write tools.',
32
33
  '- With non-root API tokens, call `get_permission_profile` before relying on admin helper tools or when debugging 403s. MCP admin helpers require ordinary route permissions for static admin routes such as `/admin/script/validate`, `/admin/test/run`, `/admin/flow/trigger/:id`, and `/admin/reload/*`.',
33
- '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `enable_route`, `disable_route`, `delete_route`, `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, `choose_flow_step_tool`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
34
+ '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `add_route_methods`, `public_route_methods`, `set_table_graphql`, guard/permission/rule tools, websocket tools, flow tools, and `ensure_page_extension`/menu tools.',
34
35
  '- Before saving standalone dynamic script or extension code, call `validate_dynamic_script` or `validate_extension_code` unless the chosen ensure/update tool already validates the code.',
35
36
  '- For existing script-backed records, use `trace_metadata_usage` then `get_script_source`; edit with `patch_script_source` or `update_script_source` so source is hash-checked and validated.',
36
37
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
37
38
  '',
38
39
  '### Core Contracts',
39
- '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Large arrays of objects may be encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }` only when that is smaller than raw JSON; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings and includes whether compression was applied; use it only when the user asks about savings.',
40
+ '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. If rows are columnar, read values by matching `columns[index]` to `rows[n][index]`; do not guess row keys.',
40
41
  '- `query_table`, `get_all_routes`, and `get_all_tables` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
41
42
  '- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
42
43
  '- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
43
- '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
44
+ '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `@REPOS.secure.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
45
+ '- Dynamic repositories have two trust paths. Use secure `@REPOS.main` or `@REPOS.secure.<table>` for user-facing data. `@REPOS.<table>` is trusted/internal and can see hidden fields; never return raw trusted rows to users.',
46
+ '- Secure repository choice is not a substitute for authorization. Handlers and hooks still need route access, owner/tenant filters, and explicit checks before returning or mutating records.',
47
+ '- Filters, sort helpers, counts, and aggregates over unpublished fields/private relations are sensitive data surfaces; do not expose them in user-facing endpoints.',
44
48
  '- Use `enfyra_user` as the user table. Model record links as real relations using relation `propertyName` values, not physical FK fields like `userId`, `conversationId`, `senderId`, or `memberId` in generated DB code.',
45
49
  '- Relation design must stay minimal. Create the owning relation needed for writes/filters first; add `inversePropertyName` only when a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use that reverse field. For schema work, explicitly review existing relations and mention which inverses are intentionally present or intentionally omitted.',
46
50
  '- Do not call internal/no-route system tables such as `enfyra_column` or `enfyra_session` through generic CRUD. Use table/column/relation tools and route-backed tables discovered from metadata.',
47
51
  '- Custom API paths use `api_endpoint_workflow` when a handler is needed and the model should follow returned nextSteps. Use lower-level `create_route` without `mainTableId` only when intentionally creating a route shell; `create_table` is only for new persisted data.',
48
52
  '- For canonical table reads and RLS, preserve client-controlled query shape: do not override `@QUERY.fields`, `@QUERY.deep`, `@QUERY.sort`, `@QUERY.limit`, `@QUERY.page`, `@QUERY.meta`, `@QUERY.aggregate`, or `debugMode`. Merge only security filters into `@QUERY.filter`.',
53
+ '- If a REST read returns a column or relation marked `isPublished=false`, including through dotted relation fields such as `fields=owner.secret` or equivalent `deep` projections, treat it as an Enfyra core support issue. Confirm the minimal repro with `test_rest_endpoint`, tell the user to send a Cloud/support ticket with the table, field path, and response shape, and do not present route-local pre-hooks or frontend hiding as the real fix.',
49
54
  '- Script source is `sourceCode`; `compiledCode` is generated and may differ textually because macros expand. Do not warn about source/compiled mismatch unless validation or runtime behavior proves the compiled artifact is stale.',
50
55
  '- For intentional user/domain errors in scripts use `@THROW400`-style helpers or `$ctx.$throw[...]`, not `throw new Error(...)`.',
51
56
  '- Destructive operations are preview-first. Do not pass `confirm=true` until the user explicitly approves.',
@@ -2,6 +2,13 @@ import { z } from 'zod';
2
2
 
3
3
  import { fetchAPI } from './fetch.js';
4
4
  import { validateScriptSourceIfPresent } from './mutation-guards.js';
5
+ import {
6
+ assertDynamicCodeKnowledgeAck,
7
+ assertDynamicCodeKnowledgeAckIf,
8
+ assertExtensionKnowledgeAck,
9
+ dynamicCodeKnowledgeAckParam,
10
+ extensionKnowledgeAckParam,
11
+ } from './required-knowledge.js';
5
12
 
6
13
  function unwrapData(result) {
7
14
  return Array.isArray(result?.data) ? result.data : [];
@@ -656,7 +663,9 @@ async function ensureExtension(apiUrl, {
656
663
  description,
657
664
  isEnabled = true,
658
665
  version = '1.0.0',
666
+ extensionKnowledgeAckKey,
659
667
  }) {
668
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
660
669
  if (type === 'page' && !menuId) {
661
670
  throw new Error('menuId is required for page extensions. Use ensure_menu first, then ensure_page_extension.');
662
671
  }
@@ -718,6 +727,7 @@ async function ensureFlowStep(apiUrl, {
718
727
  scriptLanguage,
719
728
  timeout,
720
729
  isEnabled,
730
+ knowledgeAckKey,
721
731
  }) {
722
732
  if (!flowName && !flowId) throw new Error('Provide flowName or flowId.');
723
733
  if (flowName && flowId) throw new Error('Provide flowName or flowId, not both.');
@@ -726,6 +736,7 @@ async function ensureFlowStep(apiUrl, {
726
736
  : await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
727
737
  if (!flow) throw new Error(`Flow not found: ${flowId || flowName}`);
728
738
  const parsedConfig = parseJsonObjectArg('config', config, {});
739
+ assertDynamicCodeKnowledgeAckIf(Boolean(sourceCode && ['script', 'condition'].includes(type)), knowledgeAckKey);
729
740
  const validation = sourceCode && ['script', 'condition'].includes(type)
730
741
  ? await validateDynamicScript(apiUrl, sourceCode, scriptLanguage)
731
742
  : { validated: false, reason: 'no script validation required' };
@@ -971,7 +982,12 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
971
982
  nextSteps: blocked
972
983
  ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, overwrite: true }, reason: blocked.reason }]
973
984
  : firstRunnable
974
- ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, apply: true }, stepId: firstRunnable.id }]
985
+ ? [{
986
+ tool: 'api_endpoint_workflow',
987
+ input: { path: normalizedPath, method: methodName, apply: true },
988
+ stepId: firstRunnable.id,
989
+ requiresKnowledgeAck: firstRunnable.id === 'save_handler' ? 'dynamicCodeAckKey from get_enfyra_required_knowledge' : undefined,
990
+ }]
975
991
  : [],
976
992
  };
977
993
  }
@@ -1020,6 +1036,7 @@ async function applyApiEndpointWorkflowStep(apiUrl, state, opts, stepId) {
1020
1036
  }
1021
1037
 
1022
1038
  if (selectedStep.id === 'save_handler') {
1039
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1023
1040
  if (!endpoint.routeId) throw new Error('Route must exist before saving handler.');
1024
1041
  const body = {
1025
1042
  sourceCode: opts.sourceCode,
@@ -1097,6 +1114,9 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
1097
1114
  const operations = [];
1098
1115
  let completedEphemeralStepId = null;
1099
1116
  if (opts.apply || opts.applyAll) {
1117
+ if (opts.applyAll && state.steps.some((item) => item.id === 'save_handler' && ['pending', 'waiting'].includes(item.status))) {
1118
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1119
+ }
1100
1120
  const maxSteps = opts.applyAll ? 10 : 1;
1101
1121
  for (let i = 0; i < maxSteps; i += 1) {
1102
1122
  if (state.blocked || !state.firstRunnable) break;
@@ -1369,6 +1389,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1369
1389
  apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1370
1390
  applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1371
1391
  stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1392
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply/applyAll reaches the save_handler step. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1372
1393
  },
1373
1394
  async (input) => jsonText(await runApiEndpointWorkflow(ENFYRA_API_URL, input)),
1374
1395
  );
@@ -1392,8 +1413,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1392
1413
  overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
1393
1414
  smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test after save, e.g. {"a":"1","b":"2"}.'),
1394
1415
  smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test after save.'),
1416
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1395
1417
  },
1396
- async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody }) => {
1418
+ async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody, knowledgeAckKey }) => {
1419
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1397
1420
  const normalizedPath = normalizeRestPath(path);
1398
1421
  const methodName = normalizeMethodName(method);
1399
1422
  const { methodMap, methodIdNameMap } = await getMethodContext(ENFYRA_API_URL);
@@ -1700,8 +1723,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1700
1723
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
1701
1724
  isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
1702
1725
  description: z.string().optional().describe('Admin note.'),
1726
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when sourceCode is provided. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1703
1727
  },
1704
- async ({ path, sourceCode, scriptLanguage, isEnabled, description }) => {
1728
+ async ({ path, sourceCode, scriptLanguage, isEnabled, description, knowledgeAckKey }) => {
1729
+ assertDynamicCodeKnowledgeAckIf(sourceCode !== undefined, knowledgeAckKey);
1705
1730
  const normalizedPath = normalizeRestPath(path);
1706
1731
  const validation = sourceCode === undefined
1707
1732
  ? { validated: false, reason: 'no sourceCode' }
@@ -1730,8 +1755,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1730
1755
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1731
1756
  isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
1732
1757
  description: z.string().optional().describe('Admin note.'),
1758
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1733
1759
  },
1734
- async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description }) => {
1760
+ async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description, knowledgeAckKey }) => {
1761
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1735
1762
  if (!gatewayPath && !gatewayId) throw new Error('Provide gatewayPath or gatewayId.');
1736
1763
  if (gatewayPath && gatewayId) throw new Error('Provide gatewayPath or gatewayId, not both.');
1737
1764
  const gateway = gatewayId
@@ -1833,6 +1860,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1833
1860
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1834
1861
  timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1835
1862
  isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1863
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1836
1864
  },
1837
1865
  async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1838
1866
  ...input,
@@ -1853,6 +1881,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1853
1881
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1854
1882
  timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1855
1883
  isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1884
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1856
1885
  },
1857
1886
  async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1858
1887
  ...input,
@@ -2033,6 +2062,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2033
2062
  description: z.string().optional().describe('Extension description.'),
2034
2063
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2035
2064
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
2065
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2036
2066
  },
2037
2067
  async (input) => jsonText({
2038
2068
  action: 'page_extension_ensured',
@@ -2049,6 +2079,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2049
2079
  description: z.string().optional().describe('Extension description.'),
2050
2080
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2051
2081
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
2082
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2052
2083
  },
2053
2084
  async (input) => jsonText({
2054
2085
  action: 'global_extension_ensured',
@@ -2065,6 +2096,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2065
2096
  description: z.string().optional().describe('Extension description.'),
2066
2097
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2067
2098
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
2099
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2068
2100
  },
2069
2101
  async (input) => jsonText({
2070
2102
  action: 'widget_extension_ensured',
@@ -0,0 +1,116 @@
1
+ export const DYNAMIC_CODE_KNOWLEDGE_ACK_KEY = 'EFYRA::SECURE-REPO-CONTRACT::R9x-kelp-42Q::NO-RAW-TRUSTED';
2
+ export const EXTENSION_KNOWLEDGE_ACK_KEY = 'EFYRA::EXTENSION-THEME-CONTRACT::VIOLET-IS-NOT-A-PLAN::7mQ';
3
+
4
+ const REQUIRED_KNOWLEDGE_VERSION = '2026-06-30.secure-repo-v1';
5
+
6
+ export function dynamicCodeKnowledgeAckParam(z) {
7
+ return z.string().describe('Required dynamic-code acknowledgement key from get_enfyra_required_knowledge. Call that tool, read the dynamic server code knowledge, then pass dynamicCodeAckKey exactly.');
8
+ }
9
+
10
+ export function extensionKnowledgeAckParam(z) {
11
+ return z.string().describe('Required extension acknowledgement key from get_enfyra_required_knowledge. Call that tool, read the extension/theme knowledge, then pass extensionAckKey exactly.');
12
+ }
13
+
14
+ export function assertDynamicCodeKnowledgeAck(key) {
15
+ if (key !== DYNAMIC_CODE_KNOWLEDGE_ACK_KEY) {
16
+ throw new Error('Missing or invalid dynamic-code knowledge acknowledgement. Call get_enfyra_required_knowledge, read the dynamic server code contracts, then pass dynamicCodeAckKey as knowledgeAckKey.');
17
+ }
18
+ }
19
+
20
+ export function assertDynamicCodeKnowledgeAckIf(condition, key) {
21
+ if (condition) assertDynamicCodeKnowledgeAck(key);
22
+ }
23
+
24
+ export function assertExtensionKnowledgeAck(key) {
25
+ if (key !== EXTENSION_KNOWLEDGE_ACK_KEY) {
26
+ throw new Error('Missing or invalid extension knowledge acknowledgement. Call get_enfyra_required_knowledge, read the extension/theme contracts, then pass extensionAckKey as extensionKnowledgeAckKey.');
27
+ }
28
+ }
29
+
30
+ export function assertExtensionKnowledgeAckIf(condition, key) {
31
+ if (condition) assertExtensionKnowledgeAck(key);
32
+ }
33
+
34
+ export function buildRequiredKnowledgePayload() {
35
+ return {
36
+ version: REQUIRED_KNOWLEDGE_VERSION,
37
+ purpose: 'Read this before generating or saving Enfyra dynamic server code through MCP.',
38
+ dynamicCodeAckKey: DYNAMIC_CODE_KNOWLEDGE_ACK_KEY,
39
+ extensionAckKey: EXTENSION_KNOWLEDGE_ACK_KEY,
40
+ usage: [
41
+ 'Pass dynamicCodeAckKey exactly as knowledgeAckKey when calling MCP tools that create or update dynamic server code.',
42
+ 'Pass extensionAckKey exactly as extensionKnowledgeAckKey when calling MCP tools that create or update Enfyra extension code.',
43
+ ],
44
+ dynamicServerCode: [
45
+ {
46
+ id: 'secure-vs-trusted-repositories',
47
+ rules: [
48
+ '@REPOS.main is the secure repository for the current route main table and preserves normal route query behavior.',
49
+ '@REPOS.secure.<table> is the secure explicit-table repository. Use it for public/user-facing custom handlers, hooks, websocket scripts, flows that return data, and third-party app integrations.',
50
+ '@REPOS.<table> is the trusted internal repository. It may read/write hidden fields and is only for server-owned maintenance/admin logic that intentionally needs that access.',
51
+ 'Never return raw trusted-repository records to users. Project or sanitize output before returning it.',
52
+ ],
53
+ },
54
+ {
55
+ id: 'authorization-is-separate',
56
+ rules: [
57
+ 'Secure repository selection does not prove the user is allowed to access a record.',
58
+ 'Handlers and hooks still need route access, owner/tenant filters, membership checks, and explicit mutation authorization.',
59
+ 'For canonical table reads and RLS, merge security filters into @QUERY.filter and preserve @QUERY.fields, @QUERY.deep, @QUERY.sort, @QUERY.limit, @QUERY.page, @QUERY.meta, @QUERY.aggregate, and debugMode.',
60
+ ],
61
+ },
62
+ {
63
+ id: 'hidden-field-query-surfaces',
64
+ rules: [
65
+ 'Unpublished fields and private relations are sensitive even when the field is not selected.',
66
+ 'Do not expose filter predicate-oracle behavior over hidden fields in user-facing endpoints.',
67
+ 'Do not expose aggregate, _max, _min, _count, sort helpers, or counts over unpublished fields/private relations unless the endpoint intentionally exposes that fact.',
68
+ 'If a normal REST read returns an isPublished=false field through fields/deep/dotted projection, treat it as an Enfyra core bug and verify the minimal REST repro.',
69
+ ],
70
+ },
71
+ {
72
+ id: 'dynamic-script-shape',
73
+ rules: [
74
+ 'Use sourceCode and scriptLanguage; never send compiledCode.',
75
+ 'Prefer macros such as @BODY, @QUERY, @PARAMS, @USER, @REQ, @RES, @REPOS, @HELPERS, @STORAGE, @SOCKET, and @THROW* when available.',
76
+ 'Repository reads use filter, not where.',
77
+ 'Create/update repository calls return collection-shaped data arrays; read result.data?.[0] for a single row.',
78
+ ],
79
+ },
80
+ ],
81
+ extensions: [
82
+ {
83
+ id: 'theme-contract-first',
84
+ rules: [
85
+ 'Call get_extension_theme_contract before writing or reviewing page, widget, or global extension UI.',
86
+ 'Call get_theme_class_reference when an exact eapp-* class or Nuxt UI color mapping is needed.',
87
+ 'Use eapp-surface-*, eapp-text-*, eapp-divide-y, and eapp-divider for neutral app-shell surfaces.',
88
+ 'Use eapp-primary-* or Nuxt UI primary only for runtime-primary identity/accent intent controlled by the app color picker.',
89
+ 'Use semantic state colors only for true status/error/warning/success/info indicators, not for large KPI/list containers.',
90
+ 'Do not use raw CSS variable utilities such as text-[var(...)], bg-[var(...)], or border-[var(...)] when class tokens exist.',
91
+ 'Do not hard-code concrete palettes such as color="violet" or Tailwind palette accents for themeable UI.',
92
+ ],
93
+ },
94
+ {
95
+ id: 'extension-shell-boundary',
96
+ rules: [
97
+ 'Extension roots render inside the Enfyra admin app shell. Do not add root-level page padding such as p-4 sm:p-6 xl:p-8.',
98
+ 'Page extensions should be full-bleed by default and responsive from the first version.',
99
+ 'Do not wrap whole pages in decorative cards; use cards only for repeated items, modals, or genuinely framed tools.',
100
+ 'Use NuxtLink or Nuxt UI components with :to for visible navigation links; reserve navigateTo for imperative side effects.',
101
+ 'Admin extension links for record management should point to /data/<table>, not public website paths stored on records.',
102
+ ],
103
+ },
104
+ {
105
+ id: 'extension-runtime-contract',
106
+ rules: [
107
+ 'Save extensions as enfyra_extension Vue SFC records; no static import statements in extension code.',
108
+ 'Load app packages with getPackages(["package-name"]) inside extension runtime code.',
109
+ 'Prefer FormEditor/FormEditorLazy for direct table-backed forms when the form maps to metadata fields.',
110
+ 'For long admin setup workflows, open CommonDrawer immediately and show loading/error/content inside it.',
111
+ 'Use Widget with numeric enfyra_extension widget ids; pass safe reactive props/events and keep page-level mutation ownership in the page unless the widget intentionally owns the workflow.',
112
+ ],
113
+ },
114
+ ],
115
+ };
116
+ }
@@ -22,7 +22,15 @@ import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instruct
22
22
  import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
23
23
  import { registerTableTools } from './lib/table-tools.js';
24
24
  import { registerPlatformOperationTools } from './lib/platform-operation-tools.js';
25
- import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
25
+ import { parseRecordData, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
26
+ import {
27
+ assertDynamicCodeKnowledgeAck,
28
+ assertDynamicCodeKnowledgeAckIf,
29
+ assertExtensionKnowledgeAckIf,
30
+ buildRequiredKnowledgePayload,
31
+ dynamicCodeKnowledgeAckParam,
32
+ extensionKnowledgeAckParam,
33
+ } from './lib/required-knowledge.js';
26
34
  import { validateMainTableRoutePath } from './lib/route-guards.js';
27
35
  import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
28
36
  import {
@@ -208,6 +216,7 @@ const SCRIPT_BACKED_TABLES = [
208
216
  'enfyra_graphql',
209
217
  'enfyra_bootstrap_script',
210
218
  ];
219
+ const SCRIPT_BACKED_TABLE_SET = new Set(SCRIPT_BACKED_TABLES);
211
220
 
212
221
  const SCRIPT_SOURCE_FIELDS = [
213
222
  'sourceCode',
@@ -596,6 +605,12 @@ async function prepareGenericMutation(tableName, data) {
596
605
  });
597
606
  }
598
607
 
608
+ function assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey }) {
609
+ const payload = parseRecordData(data);
610
+ assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
611
+ assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
612
+ }
613
+
599
614
  function parseQueryParamsArg(queryParams) {
600
615
  const parsed = parseJsonArg(queryParams, {});
601
616
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -775,6 +790,16 @@ installColumnarToolFormatter(server);
775
790
  // METADATA TOOLS
776
791
  // ============================================================================
777
792
 
793
+ server.tool(
794
+ 'get_enfyra_required_knowledge',
795
+ [
796
+ 'Return required Enfyra knowledge and acknowledgement keys for MCP code-writing tools.',
797
+ 'Call this before creating or updating dynamic server code or Enfyra extension code. Read the returned contracts and pass the matching ack key into write tools.',
798
+ ].join(' '),
799
+ {},
800
+ async () => jsonContent(buildRequiredKnowledgePayload()),
801
+ );
802
+
778
803
  server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Use get_table_metadata or inspect_table for detail.', {
779
804
  includeFull: z.boolean().optional().default(false).describe('Return full raw metadata. Default false to keep MCP context small.'),
780
805
  search: z.string().optional().describe('Optional table-name/alias substring filter.'),
@@ -1051,6 +1076,7 @@ server.tool(
1051
1076
  deep: 'Nested relation fetch object keyed by relation propertyName.',
1052
1077
  },
1053
1078
  countPattern: 'For counts, query only fields=id with limit=1 and request meta. Use meta=totalCount without a filter, or meta=filterCount when a filter is supplied. MCP count_records wraps this pattern.',
1079
+ security: 'Filters, sorts, counts, and aggregate values can leak information even when a field is not selected. In generated public/user-facing APIs, do not filter, sort, count, or aggregate unpublished fields or private relations unless the endpoint intentionally exposes that fact.',
1054
1080
  deep: {
1055
1081
  shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
1056
1082
  rules: [
@@ -1180,7 +1206,7 @@ server.tool(
1180
1206
  },
1181
1207
  handler: {
1182
1208
  runs: 'Main route logic, or canonical CRUD if no handler overrides.',
1183
- 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'],
1209
+ data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@RES when response streaming is available', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main secure route main table repo', '@REPOS.secure.<table> secure explicit table repo', '@REPOS.<table> trusted internal table repo', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@PKGS', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1184
1210
  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.',
1185
1211
  returnBehavior: 'Return value becomes response body unless post-hook changes it.',
1186
1212
  },
@@ -1217,7 +1243,9 @@ server.tool(
1217
1243
  },
1218
1244
  helpers: {
1219
1245
  repos: {
1220
- scopes: '$repos.main is the canonical repository for the route main table and preserves normal route query behavior. $repos.<table> is an explicit internal repository for table-specific logic. Do not generate $repos.secure.<table>; current runtime does not expose table methods there.',
1246
+ scopes: '$repos.main is the secure repository for the route main table and preserves normal route query behavior. $repos.secure.<table> is the secure repository for explicit table access in public/user-facing custom handlers, hooks, websocket scripts, flows that return data, and third-party app integrations. $repos.<table> is a trusted internal repository for server-owned maintenance/admin logic that intentionally needs hidden fields; never return raw trusted-repo records to users.',
1247
+ security: 'Secure repos enforce the normal field visibility/projection path, including unpublished columns and relations. Trusted repos bypass that exposure boundary; if trusted access is necessary, project or sanitize the result before returning it. Authorization is still required in either path: enforce route access plus owner/tenant filters or membership checks.',
1248
+ sensitiveQuerySurface: 'Filters, sort helpers, counts, and aggregate values on unpublished fields or private relations can leak information even when the value is not selected. Do not expose aggregate, _max, _min, _count, or predicate-oracle behavior over hidden fields in generated user-facing endpoints.',
1221
1249
  mutationReturnShape: '$repos.<table>.create({ data }) and $repos.<table>.update({ id, data }) return a collection-shaped result: { data: [...], count? }. data is always an array for create/update, even for one created/updated record. If a script needs the single record object, it must read result.data[0] or result.data?.[0] ?? null.',
1222
1250
  preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
1223
1251
  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.',
@@ -1288,7 +1316,7 @@ server.tool('query_table', 'Query any route-backed table. Response is minimal un
1288
1316
  fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
1289
1317
  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.'),
1290
1318
  deep: z.string().optional().describe('Optional deep relation fetch object as JSON string. Keys must be relation propertyName values.'),
1291
- 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.'),
1319
+ 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. Do not request aggregates over hidden fields/private relations in user-facing APIs.'),
1292
1320
  }, async ({ tableName, filter, sort, page, limit, all, fields, meta, deep, aggregate }) => {
1293
1321
  if (!all && limit === undefined) {
1294
1322
  throw new Error('query_table requires either limit or all=true. Do not rely on implicit default page sizes.');
@@ -1442,8 +1470,11 @@ server.tool('create_record', 'Create a new record in any route-backed table. The
1442
1470
  tableName: z.string().describe('Table name to insert into'),
1443
1471
  data: z.string().describe('Record data as JSON string'),
1444
1472
  queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
1445
- }, async ({ tableName, data, queryParams }) => {
1473
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1474
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1475
+ }, async ({ tableName, data, queryParams, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1446
1476
  validateTableName(tableName);
1477
+ assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1447
1478
  const prepared = await prepareGenericMutation(tableName, data);
1448
1479
  const query = parseQueryParamsArg(queryParams);
1449
1480
  const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
@@ -1458,8 +1489,11 @@ server.tool('update_record', 'Update an existing record by ID using PATCH. The t
1458
1489
  id: z.string().describe('Record ID to update'),
1459
1490
  data: z.string().describe('Fields to update as JSON string'),
1460
1491
  queryParams: z.string().optional().describe('Optional query params as JSON object string for route contracts that intentionally keep workflow fields out of the validated body.'),
1461
- }, async ({ tableName, id, data, queryParams }) => {
1492
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1493
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1494
+ }, async ({ tableName, id, data, queryParams, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1462
1495
  validateTableName(tableName);
1496
+ assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1463
1497
  const prepared = await prepareGenericMutation(tableName, data);
1464
1498
  const query = parseQueryParamsArg(queryParams);
1465
1499
  const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
@@ -1511,8 +1545,9 @@ server.tool(
1511
1545
  expectedSourceSha256: z.string().optional().describe('Optional SHA-256 from get_script_source; fails if source changed.'),
1512
1546
  scriptLanguage: z.string().optional().describe('Script language to save. Defaults to existing scriptLanguage or javascript.'),
1513
1547
  apply: z.boolean().optional().default(false).describe('false returns preview only; true validates and saves.'),
1548
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply=true. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1514
1549
  },
1515
- async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply }) => {
1550
+ async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply, knowledgeAckKey }) => {
1516
1551
  const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1517
1552
  if (sourceField !== 'sourceCode') {
1518
1553
  throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_record intentionally for this legacy field.`);
@@ -1543,6 +1578,7 @@ server.tool(
1543
1578
  if (!apply) {
1544
1579
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1545
1580
  }
1581
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1546
1582
  const language = scriptLanguage || record.scriptLanguage || 'javascript';
1547
1583
  const prepared = await prepareGenericMutation(
1548
1584
  tableName,
@@ -1584,8 +1620,10 @@ server.tool(
1584
1620
  id: z.string().describe('Record ID to update'),
1585
1621
  sourceCode: z.string().describe('Editable script sourceCode. Pass the raw code string; do not JSON-escape it yourself.'),
1586
1622
  scriptLanguage: z.string().optional().default('javascript').describe('Script language, usually javascript or typescript'),
1623
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1587
1624
  },
1588
- async ({ tableName, id, sourceCode, scriptLanguage }) => {
1625
+ async ({ tableName, id, sourceCode, scriptLanguage, knowledgeAckKey }) => {
1626
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1589
1627
  validateTableName(tableName);
1590
1628
  const prepared = await prepareGenericMutation(
1591
1629
  tableName,
@@ -2472,8 +2510,10 @@ server.tool(
2472
2510
  sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic.'),
2473
2511
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2474
2512
  timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
2513
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2475
2514
  },
2476
- async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout }) => {
2515
+ async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout, knowledgeAckKey }) => {
2516
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2477
2517
  const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
2478
2518
  if (methodNames.length === 0) throw new Error('Provide method or methods');
2479
2519
  const methodMap = await getMethodMap();
@@ -2533,8 +2573,10 @@ server.tool(
2533
2573
  .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2534
2574
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2535
2575
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2576
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2536
2577
  },
2537
- async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
2578
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, knowledgeAckKey }) => {
2579
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2538
2580
  const methodMap = await getMethodMap();
2539
2581
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2540
2582
  const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_pre_hook', {
@@ -2587,8 +2629,10 @@ server.tool(
2587
2629
  .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2588
2630
  priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2589
2631
  isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2632
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2590
2633
  },
2591
- async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled }) => {
2634
+ async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, knowledgeAckKey }) => {
2635
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2592
2636
  const methodMap = await getMethodMap();
2593
2637
  const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2594
2638
  const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_post_hook', {