@enfyra/mcp-server 0.0.11 → 0.0.13
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 +136 -8
- package/package.json +3 -2
- package/src/index.mjs +10 -511
- package/src/lib/config-local.mjs +286 -0
- package/src/lib/mcp-instructions.js +26 -13
- package/src/mcp-server-entry.mjs +514 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output, cwd } from 'node:process';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SERVER_KEY = 'enfyra';
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`enfyra-mcp — write local project MCP config (Claude Code + Cursor)
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
npx @enfyra/mcp-server config [options]
|
|
13
|
+
|
|
14
|
+
Writes only under the current working directory:
|
|
15
|
+
• ./.mcp.json — Claude Code project scope
|
|
16
|
+
• ./.cursor/mcp.json — Cursor project scope
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--api-url, -a <url> ENFYRA_API_URL
|
|
20
|
+
--email, -e <email> ENFYRA_EMAIL
|
|
21
|
+
--password, -p <secret> ENFYRA_PASSWORD
|
|
22
|
+
--yes Non-interactive: no prompts (CI / scripts); use CLI, env, existing file, then defaults
|
|
23
|
+
Target — non-interactive default is both; with TTY and no target flags, you are prompted [1]/[2]/[3]:
|
|
24
|
+
--claude-code, --claude, --claude-only Only ./.mcp.json (Claude Code project scope)
|
|
25
|
+
--cursor, --cursor-only Only ./.cursor/mcp.json (Cursor)
|
|
26
|
+
Passing both target flags writes both files.
|
|
27
|
+
-h, --help Show this help
|
|
28
|
+
|
|
29
|
+
Interactive mode: asks Claude Code vs Cursor vs both if you did not pass target flags; then asks for URL / email / password
|
|
30
|
+
when missing. Existing ./.mcp.json and ./.cursor/mcp.json are used as defaults. Re-run to update.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
npx @enfyra/mcp-server config
|
|
34
|
+
npx @enfyra/mcp-server config --claude-code
|
|
35
|
+
npx @enfyra/mcp-server config --cursor --yes
|
|
36
|
+
npx @enfyra/mcp-server config -a http://localhost:3000/api -e admin@x.com -p 'secret'
|
|
37
|
+
npx @enfyra/mcp-server config --yes
|
|
38
|
+
ENFYRA_PASSWORD=secret npx @enfyra/mcp-server config --yes -e admin@x.com
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const out = {
|
|
44
|
+
apiUrl: undefined,
|
|
45
|
+
email: undefined,
|
|
46
|
+
password: undefined,
|
|
47
|
+
claude: true,
|
|
48
|
+
cursor: true,
|
|
49
|
+
help: false,
|
|
50
|
+
yes: false,
|
|
51
|
+
};
|
|
52
|
+
let pickClaude = false;
|
|
53
|
+
let pickCursor = false;
|
|
54
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
const next = () => {
|
|
57
|
+
const v = argv[i + 1];
|
|
58
|
+
if (v == null) throw new Error(`Missing value after ${a}`);
|
|
59
|
+
i += 1;
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
63
|
+
else if (a === '--yes') out.yes = true;
|
|
64
|
+
else if (a === '--api-url' || a === '-a') out.apiUrl = next();
|
|
65
|
+
else if (a === '--email' || a === '-e') out.email = next();
|
|
66
|
+
else if (a === '--password' || a === '-p') out.password = next();
|
|
67
|
+
else if (a === '--claude-only' || a === '--claude-code' || a === '--claude') pickClaude = true;
|
|
68
|
+
else if (a === '--cursor-only' || a === '--cursor') pickCursor = true;
|
|
69
|
+
else throw new Error(`Unknown argument: ${a}`);
|
|
70
|
+
}
|
|
71
|
+
out.targetExplicit = pickClaude || pickCursor;
|
|
72
|
+
if (pickClaude || pickCursor) {
|
|
73
|
+
if (pickClaude && pickCursor) {
|
|
74
|
+
out.claude = true;
|
|
75
|
+
out.cursor = true;
|
|
76
|
+
} else if (pickClaude) {
|
|
77
|
+
out.claude = true;
|
|
78
|
+
out.cursor = false;
|
|
79
|
+
} else {
|
|
80
|
+
out.claude = false;
|
|
81
|
+
out.cursor = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildServerEntry(apiUrl, email, password) {
|
|
88
|
+
return {
|
|
89
|
+
command: 'npx',
|
|
90
|
+
args: ['-y', '@enfyra/mcp-server'],
|
|
91
|
+
env: {
|
|
92
|
+
ENFYRA_API_URL: apiUrl,
|
|
93
|
+
ENFYRA_EMAIL: email,
|
|
94
|
+
ENFYRA_PASSWORD: password,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function mergeMcpFile(absPath, serverEntry) {
|
|
100
|
+
let data = { mcpServers: {} };
|
|
101
|
+
try {
|
|
102
|
+
const raw = await readFile(absPath, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
if (parsed && typeof parsed === 'object' && parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
|
105
|
+
data.mcpServers = { ...parsed.mcpServers };
|
|
106
|
+
} else if (parsed && typeof parsed === 'object') {
|
|
107
|
+
data = { ...parsed, mcpServers: parsed.mcpServers && typeof parsed.mcpServers === 'object' ? { ...parsed.mcpServers } : {} };
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e.code !== 'ENOENT') throw e;
|
|
111
|
+
}
|
|
112
|
+
data.mcpServers = { ...data.mcpServers, [SERVER_KEY]: serverEntry };
|
|
113
|
+
const dir = dirname(absPath);
|
|
114
|
+
await mkdir(dir, { recursive: true });
|
|
115
|
+
await writeFile(absPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function loadExistingEnfyraEnv(root, readClaude, readCursor) {
|
|
119
|
+
const paths = [];
|
|
120
|
+
if (readClaude) paths.push(join(root, '.mcp.json'));
|
|
121
|
+
if (readCursor) paths.push(join(root, '.cursor', 'mcp.json'));
|
|
122
|
+
if (!readClaude && readCursor) paths.push(join(root, '.mcp.json'));
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const p of paths) {
|
|
125
|
+
if (seen.has(p)) continue;
|
|
126
|
+
seen.add(p);
|
|
127
|
+
try {
|
|
128
|
+
const raw = await readFile(p, 'utf8');
|
|
129
|
+
const j = JSON.parse(raw);
|
|
130
|
+
const e = j?.mcpServers?.[SERVER_KEY]?.env;
|
|
131
|
+
if (e && typeof e === 'object' && (e.ENFYRA_API_URL || e.ENFYRA_EMAIL || e.ENFYRA_PASSWORD)) {
|
|
132
|
+
return {
|
|
133
|
+
apiUrl: typeof e.ENFYRA_API_URL === 'string' ? e.ENFYRA_API_URL : '',
|
|
134
|
+
email: typeof e.ENFYRA_EMAIL === 'string' ? e.ENFYRA_EMAIL : '',
|
|
135
|
+
password: typeof e.ENFYRA_PASSWORD === 'string' ? e.ENFYRA_PASSWORD : '',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
/* */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { apiUrl: '', email: '', password: '' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function promptTargetChoice() {
|
|
146
|
+
const rl = createInterface({ input, output });
|
|
147
|
+
const line = (await rl.question(
|
|
148
|
+
'Where should Enfyra MCP config be written?\n'
|
|
149
|
+
+ ' [1] Claude Code — ./.mcp.json\n'
|
|
150
|
+
+ ' [2] Cursor — ./.cursor/mcp.json\n'
|
|
151
|
+
+ ' [3] Both [default]\n'
|
|
152
|
+
+ 'Choice [3]: ',
|
|
153
|
+
)).trim().toLowerCase();
|
|
154
|
+
await rl.close();
|
|
155
|
+
if (line === '' || line === '3' || line === 'both' || line === 'b') {
|
|
156
|
+
return { claude: true, cursor: true };
|
|
157
|
+
}
|
|
158
|
+
if (line === '1' || line === 'c' || line === 'claude') {
|
|
159
|
+
return { claude: true, cursor: false };
|
|
160
|
+
}
|
|
161
|
+
if (line === '2' || line === 'u' || line === 'cursor') {
|
|
162
|
+
return { claude: false, cursor: true };
|
|
163
|
+
}
|
|
164
|
+
return { claude: true, cursor: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function promptConfig(opts, existing) {
|
|
168
|
+
let apiUrl = opts.apiUrl;
|
|
169
|
+
let email = opts.email;
|
|
170
|
+
let password = opts.password;
|
|
171
|
+
if (apiUrl !== undefined && email !== undefined && password !== undefined) {
|
|
172
|
+
return { apiUrl: String(apiUrl).replace(/\/$/, ''), email, password };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rl = createInterface({ input, output });
|
|
176
|
+
const q = (msg) => rl.question(msg);
|
|
177
|
+
|
|
178
|
+
const defaultUrl = (
|
|
179
|
+
opts.apiUrl ??
|
|
180
|
+
process.env.ENFYRA_API_URL ??
|
|
181
|
+
(existing.apiUrl || undefined) ??
|
|
182
|
+
'http://localhost:3000/api'
|
|
183
|
+
).replace(/\/$/, '');
|
|
184
|
+
if (apiUrl === undefined) {
|
|
185
|
+
const line = (await q(`ENFYRA_API_URL [${defaultUrl}]: `)).trim();
|
|
186
|
+
apiUrl = line || defaultUrl;
|
|
187
|
+
}
|
|
188
|
+
apiUrl = String(apiUrl).replace(/\/$/, '');
|
|
189
|
+
|
|
190
|
+
const defaultEmail = opts.email ?? process.env.ENFYRA_EMAIL ?? existing.email ?? '';
|
|
191
|
+
if (email === undefined) {
|
|
192
|
+
const hint = defaultEmail ? `[${defaultEmail}]` : '[empty]';
|
|
193
|
+
const line = (await q(`ENFYRA_EMAIL ${hint}: `)).trim();
|
|
194
|
+
email = line || defaultEmail;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const defaultPass = opts.password ?? process.env.ENFYRA_PASSWORD ?? existing.password ?? '';
|
|
198
|
+
if (password === undefined) {
|
|
199
|
+
const hint = existing.password ? '(Enter = keep current)' : '(optional)';
|
|
200
|
+
const line = (await q(`ENFYRA_PASSWORD ${hint}: `)).trim();
|
|
201
|
+
password = line !== '' ? line : defaultPass;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await rl.close();
|
|
205
|
+
return { apiUrl, email, password };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveNonInteractive(opts, existing) {
|
|
209
|
+
const apiUrl = (
|
|
210
|
+
opts.apiUrl ??
|
|
211
|
+
process.env.ENFYRA_API_URL ??
|
|
212
|
+
(existing.apiUrl || undefined) ??
|
|
213
|
+
'http://localhost:3000/api'
|
|
214
|
+
).replace(/\/$/, '');
|
|
215
|
+
const email = opts.email ?? process.env.ENFYRA_EMAIL ?? existing.email ?? '';
|
|
216
|
+
const password = opts.password ?? process.env.ENFYRA_PASSWORD ?? existing.password ?? '';
|
|
217
|
+
return { apiUrl, email, password };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function runLocalConfig(argv) {
|
|
221
|
+
let opts;
|
|
222
|
+
try {
|
|
223
|
+
opts = parseArgs(argv);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error(e.message || e);
|
|
226
|
+
printHelp();
|
|
227
|
+
process.exit(1);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (opts.help) {
|
|
231
|
+
printHelp();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const root = cwd();
|
|
236
|
+
const usePrompt = !opts.yes && input.isTTY && output.isTTY;
|
|
237
|
+
|
|
238
|
+
let writeClaude = opts.claude;
|
|
239
|
+
let writeCursor = opts.cursor;
|
|
240
|
+
if (usePrompt && !opts.targetExplicit) {
|
|
241
|
+
const t = await promptTargetChoice();
|
|
242
|
+
writeClaude = t.claude;
|
|
243
|
+
writeCursor = t.cursor;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const existing = await loadExistingEnfyraEnv(root, true, true);
|
|
247
|
+
|
|
248
|
+
let apiUrl;
|
|
249
|
+
let email;
|
|
250
|
+
let password;
|
|
251
|
+
if (usePrompt) {
|
|
252
|
+
const resolved = await promptConfig(opts, existing);
|
|
253
|
+
apiUrl = resolved.apiUrl;
|
|
254
|
+
email = resolved.email;
|
|
255
|
+
password = resolved.password;
|
|
256
|
+
} else {
|
|
257
|
+
const resolved = resolveNonInteractive(opts, existing);
|
|
258
|
+
apiUrl = resolved.apiUrl;
|
|
259
|
+
email = resolved.email;
|
|
260
|
+
password = resolved.password;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const serverEntry = buildServerEntry(apiUrl, email, password);
|
|
264
|
+
const written = [];
|
|
265
|
+
|
|
266
|
+
if (writeClaude) {
|
|
267
|
+
const p = join(root, '.mcp.json');
|
|
268
|
+
await mergeMcpFile(p, serverEntry);
|
|
269
|
+
written.push(p);
|
|
270
|
+
}
|
|
271
|
+
if (writeCursor) {
|
|
272
|
+
const p = join(root, '.cursor', 'mcp.json');
|
|
273
|
+
await mergeMcpFile(p, serverEntry);
|
|
274
|
+
written.push(p);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log('Enfyra MCP — local config updated:\n');
|
|
278
|
+
for (const p of written) console.log(` ${p}`);
|
|
279
|
+
console.log('\nNext steps:');
|
|
280
|
+
console.log(' • Claude Code: open this folder; approve project MCP if prompted (`claude mcp reset-project-choices` to reset).');
|
|
281
|
+
console.log(' • Cursor: restart Cursor or reload MCP; confirm server under Settings → MCP.');
|
|
282
|
+
console.log(' • Run `config` again anytime to change values (same files are merged/overwritten for `enfyra`).');
|
|
283
|
+
if (!email || !password) {
|
|
284
|
+
console.log('\nWarning: ENFYRA_EMAIL or ENFYRA_PASSWORD is empty — tools may not authenticate until set.');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Maintain all assistant-facing rules here (and tool descriptions in index.mjs).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/** GraphQL
|
|
7
|
+
/** GraphQL SDL + HTTP endpoint are under the same base as REST: `{ENFYRA_API_URL}/graphql` and `{ENFYRA_API_URL}/graphql-schema` (Nuxt proxies these when base ends with `/api`). */
|
|
8
8
|
export function buildGraphqlUrls(apiBaseUrl) {
|
|
9
9
|
const base = String(apiBaseUrl || '').replace(/\/$/, '');
|
|
10
10
|
return {
|
|
@@ -28,6 +28,11 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
28
28
|
`**API base for this session:** \`${base}\` (from env ENFYRA_API_URL, no trailing slash).`,
|
|
29
29
|
`**Full URL:** base + path segment. Example for table \`post\`: \`${examplePost}\`.`,
|
|
30
30
|
'',
|
|
31
|
+
'### ENFYRA_API_URL (two valid setups)',
|
|
32
|
+
'- **Via Nuxt admin (typical):** `http://localhost:3000/api` — Nuxt proxies `/api/*` to Nest (`API_URL`, e.g. `http://localhost:1105`). Use this when MCP talks to the app origin.',
|
|
33
|
+
'- **Direct to Nest:** `http://localhost:1105` — no `/api` suffix on default Nest. Wrong: `http://localhost:1105/api/table_definition` (404) unless a proxy adds `/api`.',
|
|
34
|
+
'- GraphQL: `{base}/graphql` and `{base}/graphql-schema` always share this same base.',
|
|
35
|
+
'',
|
|
31
36
|
'### After a new table is created',
|
|
32
37
|
'- Enfyra creates a route at `/{table_name}` using the table **name** from `create_table` (not the alias).',
|
|
33
38
|
'- **Four REST HTTP operations** on that resource:',
|
|
@@ -84,9 +89,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
84
89
|
'- On **SQL**, filters often use **`id`**. On **MongoDB**, documents may use **`_id`** — a filter for one row might be `{"_id":{"_eq":"..."}}` instead of `id`, depending on metadata.',
|
|
85
90
|
'',
|
|
86
91
|
'### GraphQL (same prefix as REST / ENFYRA_API_URL)',
|
|
87
|
-
`- **POST** \`${graphqlHttpUrl}\` — GraphQL endpoint (body: GraphQL query).
|
|
88
|
-
`- **GET** \`${graphqlSchemaUrl}\` — current schema SDL (text)
|
|
89
|
-
'- A table appears in the schema
|
|
92
|
+
`- **POST** \`${graphqlHttpUrl}\` — GraphQL endpoint (body: GraphQL query). With Nuxt base: e.g. \`http://localhost:3000/api/graphql\`. With direct Nest: e.g. \`http://localhost:1105/graphql\`.`,
|
|
93
|
+
`- **GET** \`${graphqlSchemaUrl}\` — current schema SDL (text); same base pattern as above.`,
|
|
94
|
+
'- A table appears in the schema if some **enabled route** for that table has **both** `GQL_QUERY` and `GQL_MUTATION` in `availableMethods` and a **`mainTable`** pointing at the table. The route **`path` does not need to be** `/<table_name>` (custom paths still qualify).',
|
|
90
95
|
'- **Query** field = same string as `table_definition.name`. **Mutations** are literal concat: `create_`+tableName, `update_`+tableName, `delete_`+tableName (e.g. tableName `post` → `create_post`, input type `postInput`). See `generate-type-defs.ts`. No mutations if no non-PK columns for input.',
|
|
91
96
|
'- **Auth:** `publishedMethods` may include `GQL_QUERY` and/or `GQL_MUTATION` **separately** — each controls anonymous access for queries vs mutations. Otherwise Bearer JWT + `routePermissions` must list the same method key (`GQL_QUERY` / `GQL_MUTATION`).',
|
|
92
97
|
'- MCP does not wrap GraphQL; use REST tools or tell users the URLs above.',
|
|
@@ -97,19 +102,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
97
102
|
'- **Event** (`websocket_event_definition`): `gateway` → gateway id, `eventName` (client emits), `handlerScript`, `timeout`, `isEnabled`.',
|
|
98
103
|
'- **@SOCKET** in scripts: Connection handler — `@SOCKET.emit(event, data)` → this client; `@SOCKET.to(room).emit(event, data)` → room. Event handler — `@SOCKET.emit` → broadcast namespace; `@SOCKET.send` → this client; `@SOCKET.to(room).emit` → room.',
|
|
99
104
|
'- **Context**: Connection — `@BODY` = {id, ip, headers}, `@USER` if auth. Event — `@BODY` = payload, `@USER` if auth. Both have `@SOCKET`.',
|
|
100
|
-
'- **Client**: `io("
|
|
105
|
+
'- **Client**: `io("<HTTP_ORIGIN>/namespace", {auth: {token: JWT}})`. Use the **origin where Socket.IO is served** (usually the **Nest** HTTP origin, e.g. `http://localhost:1105/chat` in local server-only setups). If Socket.IO is exposed only through the Nuxt app, use that host and your deployment’s WS path—**do not** assume port 3000 without checking `API_URL` / proxy config. Gateway `path` in metadata = Socket.IO **namespace**.',
|
|
101
106
|
'- **Workflow**: Create gateway → `create_record` on `websocket_definition`. Create event → `create_record` on `websocket_event_definition` with `gateway: {id}`. Changes auto-reload; test handlers before saving.',
|
|
102
107
|
'',
|
|
103
108
|
'### Flows (Automated Workflows)',
|
|
104
109
|
'- Enfyra supports automated workflows via **`flow_definition`**, **`flow_step_definition`**, and **`flow_execution_definition`** tables.',
|
|
105
|
-
'- **Flow** (`flow_definition`): `name`, `triggerType` (`schedule`, `
|
|
106
|
-
'- **Step** (`flow_step_definition`): `flow` → flow id, `key` (unique identifier for data chain), `stepOrder`, `type` (`script`, `condition`, `query`, `create`, `update`, `delete`, `http`, `trigger_flow`, `sleep`, `log`), `config` (JSON), `timeout`, `onError` (`stop`, `skip`, `retry`), `retryAttempts
|
|
107
|
-
'- **Execution history** (`flow_execution_definition`): `flow` → flow id, `status`, `
|
|
108
|
-
'- **triggerConfig examples**: schedule: `{"cron":"0 2 * * *","timezone":"UTC"}`,
|
|
109
|
-
'- **Step config examples**: script: `{"code":"return
|
|
110
|
-
'- **Data chain**: Steps access previous results via
|
|
111
|
-
'- **
|
|
112
|
-
'- **
|
|
110
|
+
'- **Flow** (`flow_definition`): `name`, `triggerType` (`schedule`, `manual`), `triggerConfig` (JSON), `timeout`, `maxExecutions` (default 100, auto-cleanup old history), `isEnabled`.',
|
|
111
|
+
'- **Step** (`flow_step_definition`): `flow` → flow id, `key` (unique identifier for data chain), `stepOrder`, `type` (`script`, `condition`, `query`, `create`, `update`, `delete`, `http`, `trigger_flow`, `sleep`, `log`), `config` (JSON), `timeout`, `onError` (`stop`, `skip`, `retry`), `retryAttempts`, `parent` → self-ref to condition step (null = root), `branch` (`true`/`false` — which branch of parent condition).',
|
|
112
|
+
'- **Execution history** (`flow_execution_definition`): `flow` → flow id, `status`, `payload`, `context` (full data chain), `completedSteps`, `currentStep`, `error`, `startedAt`, `completedAt`, `duration`. Query separately — NOT nested under flow_definition.',
|
|
113
|
+
'- **triggerConfig examples**: schedule: `{"cron":"0 2 * * *","timezone":"UTC"}`, manual: `{}`. For event/webhook use cases, create a handler/hook with `@DISPATCH.trigger("flow-name", payload)` instead.',
|
|
114
|
+
'- **Step config examples**: script: `{"code":"return #user_definition.find({limit:10})"}`, condition: `{"code":"return @LAST?.data?.length > 0"}` (uses JS truthy/falsy: `return user` = truthy if exists, `return null` = falsy), query: `{"table":"user_definition","filter":{"status":{"_eq":"active"}},"limit":10}`, http: `{"url":"https://api.example.com","method":"POST","body":{}}` (auto Content-Type: application/json; **http `url` must be public-safe**—see Safety), sleep: `{"ms":5000}`, trigger_flow: `{"flowId":2}`.',
|
|
115
|
+
'- **Data chain**: Steps access previous results via `@FLOW.<stepKey>` and `@LAST`. Input payload via `@PAYLOAD`. Repos via `#table_name`.',
|
|
116
|
+
'- **Template syntax (flows)**: `@PAYLOAD` → `$ctx.$flow.$payload` (input data), `@LAST` → `$ctx.$flow.$last`, `@FLOW` → `$ctx.$flow`, `@META` → `$ctx.$flow.$meta`, `#table_name` → `$ctx.$repos.table_name`, `@HELPERS` → `$ctx.$helpers`, `@THROW4xx/5xx` → error helpers. Trigger other flows in handlers via `@DISPATCH.trigger(name, payload)` or `$ctx.$dispatch.trigger(name, payload)`.',
|
|
117
|
+
'- **Condition branching**: Condition step uses JavaScript truthy/falsy evaluation (e.g. `return user` → truthy if exists, falsy if null/0/undefined). Children with matching `parent: {id: conditionStepId}` and `branch: "true"/"false"` execute. Root steps (no parent) always execute sequentially.',
|
|
118
|
+
'- **Safety**: Max nesting depth 10 (flow triggering flow). Circular flow detection prevents A→B→A loops. HTTP steps: **SSRF hardening** — only `http`/`https`; blocks `localhost`, private IPs, and hostnames resolving to private IPs (use internet-facing URLs like `https://api.example.com`, not internal services, unless server policy changes). Default HTTP timeout 30s (AbortController). `$dispatch.trigger()` available inside flow steps.',
|
|
119
|
+
'- **Workflow**: Create flow → `create_record` on `flow_definition`. Add steps → `create_record` on `flow_step_definition` with `flow: {id}`. For branch steps, set `parent: {id: conditionStepId}` and `branch: "true"` or `"false"`. Trigger manually via `POST /admin/flow/trigger/{flowId}`.',
|
|
120
|
+
'- **Test step**: `POST /admin/flow/test-step` with body `{type, config, timeout}` — runs a single step without saving, returns `{success, result, error, duration}`.',
|
|
121
|
+
'- **In handlers/hooks**: Trigger flows via `$ctx.$dispatch.trigger("flow-name", {payload})` or `$ctx.$dispatch.trigger(flowId, {payload})`.',
|
|
113
122
|
'',
|
|
114
123
|
'### Extension (Vue SFC only — NOT React)',
|
|
115
124
|
'- **CRITICAL:** MUST call `create_record` or `update_record` on `extension_definition` — outputting Vue code in chat does NOT save it. User will NOT see it.',
|
|
@@ -164,6 +173,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
164
173
|
'- `$ctx` — runtime context',
|
|
165
174
|
'',
|
|
166
175
|
'#### Extension types:',
|
|
176
|
+
'- **FormEditor field-map:** Customize fields via `:field-map` prop. Options: `label` (override label), `description` (override description), `hideLabel`/`hideDescription` (boolean), `component` (custom Vue component replacing field), `componentProps`, `type` (override field type), `disabled`, `placeholder`. Custom component receives `modelValue`/`update:modelValue`.',
|
|
167
177
|
'- **type "page":** Full-page extension. Requires `menu: { id }` — create menu first (`create_menu` or `create_record` on `menu_definition`), find by path/label, then create extension with `menu: { id: menuId }`. `menu_definition` uses **label** not name — filter by `label` or `path`.',
|
|
168
178
|
'- **type "widget":** Widget extension. No menu required. Embed via `<Widget :id="extensionId" />` in other extensions or pages.',
|
|
169
179
|
'',
|
|
@@ -185,6 +195,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
185
195
|
'#### Minimal example:',
|
|
186
196
|
'`<template><div class="p-6"><h1 class="text-2xl font-bold">{{ title }}</h1><UButton @click="handleClick">Click</UButton></div></template><script setup>const title = ref(\'My Extension\'); const toast = useToast(); const handleClick = () => toast.add({ title: \'Clicked\', color: \'green\' });</script>`',
|
|
187
197
|
'',
|
|
198
|
+
'### API Testing',
|
|
199
|
+
'- API testing is available at `/settings/api-tester` in the app UI.',
|
|
200
|
+
'',
|
|
188
201
|
'### MCP tool → HTTP',
|
|
189
202
|
`- \`get_all_metadata\` → GET \`${base}/metadata\``,
|
|
190
203
|
`- \`get_table_metadata\` → GET \`${base}/metadata/<tableName>\``,
|