@enfyra/mcp-server 0.0.43 → 0.0.45

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
@@ -17,9 +17,9 @@ From your **Enfyra project root**:
17
17
  npx @enfyra/mcp-server config
18
18
  ```
19
19
 
20
- - **Interactive (default in a terminal):** first asks **where** to write config with an arrow-key selector — Claude Code, Cursor, Codex, or all — unless you already passed target flags. Then prompts for `ENFYRA_API_URL`, `ENFYRA_EMAIL`, and `ENFYRA_PASSWORD` when missing. Press **Enter** to accept bracketed defaults from env or existing `enfyra` config. Password **Enter** keeps the current saved password when updating.
20
+ - **Interactive (default in a terminal):** first asks **where** to write config with an arrow-key selector — Claude Code, Cursor, Codex, or all — unless you already passed target flags. Then prompts for `ENFYRA_API_URL` and `ENFYRA_API_TOKEN` when missing. Press **Enter** to accept bracketed defaults from env or existing `enfyra` config.
21
21
  - **Re-run anytime** to update the same files; other entries under `mcpServers` are preserved.
22
- - **Non-interactive** (CI / scripts): `npx @enfyra/mcp-server config --yes` plus optional `-a` / `-e` / `-p` and/or env vars.
22
+ - **Non-interactive** (CI / scripts): `npx @enfyra/mcp-server config --yes` plus optional `-a` / `-t` and/or env vars.
23
23
  - **One host only:** `--claude-code` / `--claude` / `--claude-only` → `./.mcp.json`. `--cursor` / `--cursor-only` → `./.cursor/mcp.json`. `--codex` / `--codex-only` → `./.codex/config.toml`. Pass multiple target flags to write each selected host.
24
24
  - **Reconfigure:** `npx @enfyra/mcp-server config --reconfig` prompts for the target host again, uses existing project values as defaults, and replaces the old project `enfyra` entry for that host.
25
25
  - **Global/user config:** add `--global` only when you intentionally want the selected host config under your home directory instead of this project.
@@ -57,8 +57,7 @@ Non-interactive:
57
57
  ```bash
58
58
  npx @enfyra/mcp-server config --codex --yes \
59
59
  -a http://localhost:3000/api \
60
- -e your-email@example.com \
61
- -p your-password
60
+ -t efy_pat_your-token
62
61
  ```
63
62
 
64
63
  The generated TOML section is:
@@ -70,8 +69,7 @@ args = ["-y", "@enfyra/mcp-server"]
70
69
 
71
70
  [mcp_servers.enfyra.env]
72
71
  ENFYRA_API_URL = "http://localhost:3000/api"
73
- ENFYRA_EMAIL = "your-email@example.com"
74
- ENFYRA_PASSWORD = "your-password"
72
+ ENFYRA_API_TOKEN = "efy_pat_your-token"
75
73
  ```
76
74
 
77
75
  The config writer replaces only `[mcp_servers.enfyra]` and `[mcp_servers.enfyra.env]`; other Codex config and other MCP servers are preserved. Open this folder in a new Codex session after updating `./.codex/config.toml`. Use `--global --codex` only when you intentionally want `~/.codex/config.toml`.
@@ -103,22 +101,19 @@ Use the CLI (recommended). **User** and **local** configs are stored in **`~/.cl
103
101
  # User scope — available in all projects (options before server name per Claude Code docs)
104
102
  claude mcp add --transport stdio --scope user \
105
103
  --env ENFYRA_API_URL=http://localhost:3000/api \
106
- --env ENFYRA_EMAIL=your-email@example.com \
107
- --env ENFYRA_PASSWORD=your-password \
104
+ --env ENFYRA_API_TOKEN=efy_pat_your-token \
108
105
  enfyra -- npx -y @enfyra/mcp-server
109
106
 
110
107
  # Local scope (default) — only when this repo is cwd; still stored in ~/.claude.json under project path
111
108
  claude mcp add --transport stdio \
112
109
  --env ENFYRA_API_URL=http://localhost:3000/api \
113
- --env ENFYRA_EMAIL=your-email@example.com \
114
- --env ENFYRA_PASSWORD=your-password \
110
+ --env ENFYRA_API_TOKEN=efy_pat_your-token \
115
111
  enfyra -- npx -y @enfyra/mcp-server
116
112
 
117
113
  # Project scope — writes/updates .mcp.json at repo root (good for teams)
118
114
  claude mcp add --transport stdio --scope project \
119
115
  --env ENFYRA_API_URL=http://localhost:3000/api \
120
- --env ENFYRA_EMAIL=your-email@example.com \
121
- --env ENFYRA_PASSWORD=your-password \
116
+ --env ENFYRA_API_TOKEN=efy_pat_your-token \
122
117
  enfyra -- npx -y @enfyra/mcp-server
123
118
  ```
124
119
 
@@ -148,7 +143,7 @@ Cursor reads MCP from **`mcp.json`** in two places ([Cursor docs](https://cursor
148
143
  | **Global** | `~/.cursor/mcp.json` (macOS/Linux) or `%USERPROFILE%\.cursor\mcp.json` (Windows) |
149
144
  | **Project** | **`.cursor/mcp.json`** inside the project (directory **`.cursor`** at repo root) |
150
145
 
151
- Paste the **same** `mcpServers` structure as in the [Shared](#shared-enfyra-mcp-json-and-environment) section. Cursor supports **interpolation**, e.g. `${env:ENFYRA_PASSWORD}`, `${workspaceFolder}`, for secrets and paths.
146
+ Paste the **same** `mcpServers` structure as in the [Shared](#shared-enfyra-mcp-json-and-environment) section. Cursor supports **interpolation**, e.g. `${env:ENFYRA_API_TOKEN}`, `${workspaceFolder}`, for secrets and paths.
152
147
 
153
148
  Optional **STDIO** fields per Cursor: `type`, `command`, `args`, `env`, `envFile` — see [STDIO server configuration](https://cursor.com/docs/context/mcp).
154
149
 
@@ -172,8 +167,7 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
172
167
  "args": ["-y", "@enfyra/mcp-server"],
173
168
  "env": {
174
169
  "ENFYRA_API_URL": "http://localhost:3000/api",
175
- "ENFYRA_EMAIL": "your-email@example.com",
176
- "ENFYRA_PASSWORD": "your-password"
170
+ "ENFYRA_API_TOKEN": "efy_pat_your-token"
177
171
  }
178
172
  }
179
173
  }
@@ -186,8 +180,7 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
186
180
  | Variable | Description | Default |
187
181
  |----------|-------------|---------|
188
182
  | `ENFYRA_API_URL` | Base for REST + GraphQL + auth through the Nuxt/app proxy | `http://localhost:3000/api` |
189
- | `ENFYRA_EMAIL` | Admin email | — |
190
- | `ENFYRA_PASSWORD` | Admin password | — |
183
+ | `ENFYRA_API_TOKEN` | Programmatic token from eApp `/me`. MCP exchanges it through `/auth/token/exchange` for an access token. | — |
191
184
 
192
185
  ### `ENFYRA_API_URL` — use the app proxy
193
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.43",
3
+ "version": "0.0.45",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -20,8 +20,7 @@ Common config flags:
20
20
  --reconfig Prompt for host and credentials again, replacing the enfyra entry
21
21
  --yes Non-interactive
22
22
  -a, --api-url ENFYRA_API_URL
23
- -e, --email ENFYRA_EMAIL
24
- -p, --password ENFYRA_PASSWORD
23
+ -t, --api-token ENFYRA_API_TOKEN
25
24
  -h, --help Show config help
26
25
 
27
26
  Run \`npx @enfyra/mcp-server config --help\` for full config details.
package/src/lib/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Authentication module for Enfyra MCP Server
3
- * Handles login, token refresh, and token validation
3
+ * Handles API-token exchange and token validation
4
4
  */
5
5
 
6
6
  // Token state
@@ -11,8 +11,7 @@ let isRefreshing = false;
11
11
 
12
12
  // Config
13
13
  let API_URL = 'http://localhost:3000/api';
14
- let EMAIL = '';
15
- let PASSWORD = '';
14
+ let API_TOKEN = '';
16
15
 
17
16
  // Refresh buffer: refresh token 1 minute before expiry
18
17
  const TOKEN_REFRESH_BUFFER = 60000;
@@ -20,16 +19,16 @@ const TOKEN_REFRESH_BUFFER = 60000;
20
19
  /**
21
20
  * Initialize auth module with config
22
21
  */
23
- export function initAuth(apiUrl, email, password) {
22
+ export function initAuth(apiUrl, apiToken = '') {
24
23
  API_URL = apiUrl;
25
- EMAIL = email;
26
- PASSWORD = password;
24
+ API_TOKEN = apiToken;
27
25
  }
28
26
 
29
27
  /**
30
28
  * Check if token needs refresh (expires within 1 minute)
31
29
  */
32
30
  export function needsRefresh() {
31
+ if (tokenExpiry === Infinity) return false;
33
32
  if (!tokenExpiry) return true;
34
33
  const now = Date.now();
35
34
  return now + TOKEN_REFRESH_BUFFER >= tokenExpiry;
@@ -49,54 +48,54 @@ export function getTokenExpiry() {
49
48
  return tokenExpiry;
50
49
  }
51
50
 
52
- /**
53
- * Login and get access + refresh tokens
54
- */
55
- export async function login(url, email, password) {
51
+ export async function exchangeApiToken(url, apiToken) {
56
52
  const apiUrl = url || API_URL;
57
- const authEmail = email || EMAIL;
58
- const authPassword = password || PASSWORD;
53
+ const token = apiToken || API_TOKEN;
59
54
 
60
- if (!authEmail || !authPassword) {
61
- throw new Error('Email and password required');
55
+ if (!token) {
56
+ throw new Error('API token required');
62
57
  }
63
58
 
64
- console.error('[Auth] Logging in...');
65
- const response = await fetch(`${apiUrl}/auth/login`, {
59
+ console.error('[Auth] Exchanging API token...');
60
+ const response = await fetch(`${apiUrl}/auth/token/exchange`, {
66
61
  method: 'POST',
67
62
  headers: { 'Content-Type': 'application/json' },
68
- body: JSON.stringify({ email: authEmail, password: authPassword }),
63
+ body: JSON.stringify({ apiToken: token }),
69
64
  });
70
65
 
71
66
  if (!response.ok) {
72
- throw new Error(`Login failed: ${await response.text()}`);
67
+ throw new Error(`API token exchange failed: ${await response.text()}`);
73
68
  }
74
69
 
75
70
  const data = await response.json();
76
71
  accessToken = data.accessToken || data.access_token;
77
- refreshToken = data.refreshToken || data.refresh_token;
78
- tokenExpiry = data.expTime;
72
+ refreshToken = null;
73
+ tokenExpiry = data.expTime == null ? Infinity : data.expTime;
79
74
 
80
- console.error(`[Auth] Logged in as ${authEmail}, token expires at ${new Date(tokenExpiry).toISOString()}`);
75
+ const expiryLabel = tokenExpiry === Infinity
76
+ ? 'no expiration'
77
+ : new Date(tokenExpiry).toISOString();
78
+ console.error(`[Auth] API token exchanged, access token expires at ${expiryLabel}`);
81
79
  return accessToken;
82
80
  }
83
81
 
84
82
  /**
85
83
  * Refresh access token using refresh token
86
84
  */
87
- export async function refreshAccessToken(url, email, password) {
85
+ export async function refreshAccessToken(url) {
88
86
  const apiUrl = url || API_URL;
89
- const authEmail = email || EMAIL;
90
- const authPassword = password || PASSWORD;
91
87
 
92
88
  if (isRefreshing) {
93
89
  await new Promise(resolve => setTimeout(resolve, 500));
94
90
  return accessToken;
95
91
  }
96
92
 
93
+ if (API_TOKEN) {
94
+ return await exchangeApiToken(apiUrl, API_TOKEN);
95
+ }
96
+
97
97
  if (!refreshToken) {
98
- console.error('[Auth] No refresh token, performing fresh login');
99
- return await login(apiUrl, authEmail, authPassword);
98
+ throw new Error('ENFYRA_API_TOKEN required');
100
99
  }
101
100
 
102
101
  isRefreshing = true;
@@ -109,9 +108,8 @@ export async function refreshAccessToken(url, email, password) {
109
108
  });
110
109
 
111
110
  if (!response.ok) {
112
- console.error('[Auth] Refresh failed, logging in fresh');
113
111
  refreshToken = null;
114
- return await login(apiUrl, authEmail, authPassword);
112
+ return await exchangeApiToken(apiUrl, API_TOKEN);
115
113
  }
116
114
 
117
115
  const data = await response.json();
@@ -129,16 +127,17 @@ export async function refreshAccessToken(url, email, password) {
129
127
  /**
130
128
  * Get valid access token, refreshing if needed
131
129
  */
132
- export async function getValidToken(url, email, password) {
130
+ export async function getValidToken(url) {
133
131
  const apiUrl = url || API_URL;
134
- const authEmail = email || EMAIL;
135
- const authPassword = password || PASSWORD;
136
132
 
137
133
  if (!accessToken || needsRefresh()) {
134
+ if (API_TOKEN) {
135
+ return await exchangeApiToken(apiUrl, API_TOKEN);
136
+ }
138
137
  if (refreshToken) {
139
- return await refreshAccessToken(apiUrl, authEmail, authPassword);
138
+ return await refreshAccessToken(apiUrl);
140
139
  }
141
- return await login(apiUrl, authEmail, authPassword);
140
+ throw new Error('ENFYRA_API_TOKEN required');
142
141
  }
143
142
  return accessToken;
144
143
  }
@@ -20,8 +20,7 @@ Writes project config under the current working directory:
20
20
 
21
21
  Options:
22
22
  --api-url, -a <url> ENFYRA_API_URL
23
- --email, -e <email> ENFYRA_EMAIL
24
- --password, -p <secret> ENFYRA_PASSWORD
23
+ --api-token, -t <secret> ENFYRA_API_TOKEN
25
24
  --global Write global/user config for selected hosts instead of project config
26
25
  --reconfig Always choose target again in interactive mode and replace the old enfyra config for that target
27
26
  --yes Non-interactive: no prompts (CI / scripts); use CLI, env, existing file, then defaults
@@ -32,7 +31,7 @@ Options:
32
31
  Passing multiple target flags writes each selected target.
33
32
  -h, --help Show this help
34
33
 
35
- Interactive mode: lets you choose Claude Code / Cursor / Codex / all if you did not pass target flags; then asks for URL / email / password
34
+ Interactive mode: lets you choose Claude Code / Cursor / Codex / all if you did not pass target flags; then asks for URL / API token
36
35
  when missing. Existing project config is used as defaults. Re-run to update.
37
36
 
38
37
  Examples:
@@ -42,17 +41,16 @@ Examples:
42
41
  npx @enfyra/mcp-server config --codex --yes
43
42
  npx @enfyra/mcp-server config --global --codex
44
43
  npx @enfyra/mcp-server config --reconfig
45
- npx @enfyra/mcp-server config -a http://localhost:3000/api -e admin@x.com -p 'secret'
44
+ npx @enfyra/mcp-server config -a http://localhost:3000/api -t 'efy_pat_...'
46
45
  npx @enfyra/mcp-server config --yes
47
- ENFYRA_PASSWORD=secret npx @enfyra/mcp-server config --yes -e admin@x.com
46
+ ENFYRA_API_TOKEN=efy_pat_... npx @enfyra/mcp-server config --yes
48
47
  `);
49
48
  }
50
49
 
51
50
  function parseArgs(argv) {
52
51
  const out = {
53
52
  apiUrl: undefined,
54
- email: undefined,
55
- password: undefined,
53
+ apiToken: undefined,
56
54
  claude: true,
57
55
  cursor: true,
58
56
  codex: true,
@@ -78,8 +76,10 @@ function parseArgs(argv) {
78
76
  else if (a === '--reconfig') out.reconfig = true;
79
77
  else if (a === '--global') out.global = true;
80
78
  else if (a === '--api-url' || a === '-a') out.apiUrl = next();
81
- else if (a === '--email' || a === '-e') out.email = next();
82
- else if (a === '--password' || a === '-p') out.password = next();
79
+ else if (a === '--api-token' || a === '-t') out.apiToken = next();
80
+ else if (a === '--email' || a === '-e' || a === '--password' || a === '-p') {
81
+ throw new Error(`${a} is no longer supported; use --api-token instead`);
82
+ }
83
83
  else if (a === '--claude-only' || a === '--claude-code' || a === '--claude') pickClaude = true;
84
84
  else if (a === '--cursor-only' || a === '--cursor') pickCursor = true;
85
85
  else if (a === '--codex-only' || a === '--codex') pickCodex = true;
@@ -94,14 +94,13 @@ function parseArgs(argv) {
94
94
  return out;
95
95
  }
96
96
 
97
- function buildServerEntry(apiUrl, email, password) {
97
+ function buildServerEntry(apiUrl, apiToken) {
98
98
  return {
99
99
  command: 'npx',
100
100
  args: ['-y', '@enfyra/mcp-server'],
101
101
  env: {
102
102
  ENFYRA_API_URL: apiUrl,
103
- ENFYRA_EMAIL: email,
104
- ENFYRA_PASSWORD: password,
103
+ ENFYRA_API_TOKEN: apiToken,
105
104
  },
106
105
  };
107
106
  }
@@ -129,7 +128,7 @@ function tomlString(value) {
129
128
  return JSON.stringify(String(value ?? ''));
130
129
  }
131
130
 
132
- function buildCodexTomlBlock(apiUrl, email, password) {
131
+ function buildCodexTomlBlock(apiUrl, apiToken) {
133
132
  return [
134
133
  '[mcp_servers.enfyra]',
135
134
  'command = "npx"',
@@ -137,13 +136,12 @@ function buildCodexTomlBlock(apiUrl, email, password) {
137
136
  '',
138
137
  '[mcp_servers.enfyra.env]',
139
138
  `ENFYRA_API_URL = ${tomlString(apiUrl)}`,
140
- `ENFYRA_EMAIL = ${tomlString(email)}`,
141
- `ENFYRA_PASSWORD = ${tomlString(password)}`,
139
+ `ENFYRA_API_TOKEN = ${tomlString(apiToken)}`,
142
140
  '',
143
141
  ].join('\n');
144
142
  }
145
143
 
146
- async function mergeCodexConfig(absPath, apiUrl, email, password) {
144
+ async function mergeCodexConfig(absPath, apiUrl, apiToken) {
147
145
  let raw = '';
148
146
  try {
149
147
  raw = await readFile(absPath, 'utf8');
@@ -162,7 +160,7 @@ async function mergeCodexConfig(absPath, apiUrl, email, password) {
162
160
  if (!skip) kept.push(line);
163
161
  }
164
162
 
165
- const next = `${kept.join('\n').trimEnd()}\n\n${buildCodexTomlBlock(apiUrl, email, password)}`;
163
+ const next = `${kept.join('\n').trimEnd()}\n\n${buildCodexTomlBlock(apiUrl, apiToken)}`;
166
164
  await mkdir(dirname(absPath), { recursive: true });
167
165
  await writeFile(absPath, next, 'utf8');
168
166
  }
@@ -180,7 +178,7 @@ function parseTomlString(value) {
180
178
  async function readCodexEnfyraEnv(absPath) {
181
179
  try {
182
180
  const raw = await readFile(absPath, 'utf8');
183
- const values = { apiUrl: '', email: '', password: '' };
181
+ const values = { apiUrl: '', apiToken: '' };
184
182
  let inEnv = false;
185
183
  for (const line of raw.split(/\r?\n/)) {
186
184
  const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
@@ -194,10 +192,9 @@ async function readCodexEnfyraEnv(absPath) {
194
192
  const key = pair[1];
195
193
  const value = parseTomlString(pair[2]);
196
194
  if (key === 'ENFYRA_API_URL') values.apiUrl = value;
197
- if (key === 'ENFYRA_EMAIL') values.email = value;
198
- if (key === 'ENFYRA_PASSWORD') values.password = value;
195
+ if (key === 'ENFYRA_API_TOKEN') values.apiToken = value;
199
196
  }
200
- if (values.apiUrl || values.email || values.password) return values;
197
+ if (values.apiUrl || values.apiToken) return values;
201
198
  } catch {
202
199
  /* */
203
200
  }
@@ -229,11 +226,10 @@ async function loadExistingEnfyraEnv(root, readClaude, readCursor, readCodex, gl
229
226
  const raw = await readFile(p, 'utf8');
230
227
  const j = JSON.parse(raw);
231
228
  const e = j?.mcpServers?.[SERVER_KEY]?.env;
232
- if (e && typeof e === 'object' && (e.ENFYRA_API_URL || e.ENFYRA_EMAIL || e.ENFYRA_PASSWORD)) {
229
+ if (e && typeof e === 'object' && (e.ENFYRA_API_URL || e.ENFYRA_API_TOKEN)) {
233
230
  return {
234
231
  apiUrl: typeof e.ENFYRA_API_URL === 'string' ? e.ENFYRA_API_URL : '',
235
- email: typeof e.ENFYRA_EMAIL === 'string' ? e.ENFYRA_EMAIL : '',
236
- password: typeof e.ENFYRA_PASSWORD === 'string' ? e.ENFYRA_PASSWORD : '',
232
+ apiToken: typeof e.ENFYRA_API_TOKEN === 'string' ? e.ENFYRA_API_TOKEN : '',
237
233
  };
238
234
  }
239
235
  } catch {
@@ -244,7 +240,7 @@ async function loadExistingEnfyraEnv(root, readClaude, readCursor, readCodex, gl
244
240
  const codex = await readCodexEnfyraEnv(getCodexConfigPath(root, globalScope));
245
241
  if (codex) return codex;
246
242
  }
247
- return { apiUrl: '', email: '', password: '' };
243
+ return { apiUrl: '', apiToken: '' };
248
244
  }
249
245
 
250
246
  async function promptTargetChoice() {
@@ -357,10 +353,9 @@ async function promptTargetSelect(choices, initialIndex = 0) {
357
353
 
358
354
  async function promptConfig(opts, existing) {
359
355
  let apiUrl = opts.apiUrl;
360
- let email = opts.email;
361
- let password = opts.password;
362
- if (apiUrl !== undefined && email !== undefined && password !== undefined) {
363
- return { apiUrl: String(apiUrl).replace(/\/$/, ''), email, password };
356
+ let apiToken = opts.apiToken;
357
+ if (apiUrl !== undefined && apiToken !== undefined) {
358
+ return { apiUrl: String(apiUrl).replace(/\/$/, ''), apiToken };
364
359
  }
365
360
 
366
361
  const rl = createInterface({ input, output });
@@ -378,22 +373,15 @@ async function promptConfig(opts, existing) {
378
373
  }
379
374
  apiUrl = String(apiUrl).replace(/\/$/, '');
380
375
 
381
- const defaultEmail = opts.email ?? process.env.ENFYRA_EMAIL ?? existing.email ?? '';
382
- if (email === undefined) {
383
- const hint = defaultEmail ? `[${defaultEmail}]` : '[empty]';
384
- const line = (await q(`ENFYRA_EMAIL ${hint}: `)).trim();
385
- email = line || defaultEmail;
386
- }
387
-
388
- const defaultPass = opts.password ?? process.env.ENFYRA_PASSWORD ?? existing.password ?? '';
389
- if (password === undefined) {
390
- const hint = existing.password ? '(Enter = keep current)' : '(optional)';
391
- const line = (await q(`ENFYRA_PASSWORD ${hint}: `)).trim();
392
- password = line !== '' ? line : defaultPass;
376
+ const defaultApiToken = opts.apiToken ?? process.env.ENFYRA_API_TOKEN ?? existing.apiToken ?? '';
377
+ if (apiToken === undefined) {
378
+ const hint = defaultApiToken ? '(Enter = keep current)' : '(recommended)';
379
+ const line = (await q(`ENFYRA_API_TOKEN ${hint}: `)).trim();
380
+ apiToken = line !== '' ? line : defaultApiToken;
393
381
  }
394
382
 
395
383
  await rl.close();
396
- return { apiUrl, email, password };
384
+ return { apiUrl, apiToken };
397
385
  }
398
386
 
399
387
  function resolveNonInteractive(opts, existing) {
@@ -403,9 +391,8 @@ function resolveNonInteractive(opts, existing) {
403
391
  (existing.apiUrl || undefined) ??
404
392
  'http://localhost:3000/api'
405
393
  ).replace(/\/$/, '');
406
- const email = opts.email ?? process.env.ENFYRA_EMAIL ?? existing.email ?? '';
407
- const password = opts.password ?? process.env.ENFYRA_PASSWORD ?? existing.password ?? '';
408
- return { apiUrl, email, password };
394
+ const apiToken = opts.apiToken ?? process.env.ENFYRA_API_TOKEN ?? existing.apiToken ?? '';
395
+ return { apiUrl, apiToken };
409
396
  }
410
397
 
411
398
  export async function runLocalConfig(argv) {
@@ -439,21 +426,18 @@ export async function runLocalConfig(argv) {
439
426
  const existing = await loadExistingEnfyraEnv(root, writeClaude, writeCursor, writeCodex, opts.global);
440
427
 
441
428
  let apiUrl;
442
- let email;
443
- let password;
429
+ let apiToken;
444
430
  if (usePrompt) {
445
431
  const resolved = await promptConfig(opts, existing);
446
432
  apiUrl = resolved.apiUrl;
447
- email = resolved.email;
448
- password = resolved.password;
433
+ apiToken = resolved.apiToken;
449
434
  } else {
450
435
  const resolved = resolveNonInteractive(opts, existing);
451
436
  apiUrl = resolved.apiUrl;
452
- email = resolved.email;
453
- password = resolved.password;
437
+ apiToken = resolved.apiToken;
454
438
  }
455
439
 
456
- const serverEntry = buildServerEntry(apiUrl, email, password);
440
+ const serverEntry = buildServerEntry(apiUrl, apiToken);
457
441
  const written = [];
458
442
 
459
443
  if (writeClaude) {
@@ -468,7 +452,7 @@ export async function runLocalConfig(argv) {
468
452
  }
469
453
  if (writeCodex) {
470
454
  const p = getCodexConfigPath(root, opts.global);
471
- await mergeCodexConfig(p, apiUrl, email, password);
455
+ await mergeCodexConfig(p, apiUrl, apiToken);
472
456
  written.push(p);
473
457
  }
474
458
 
@@ -479,7 +463,7 @@ export async function runLocalConfig(argv) {
479
463
  console.log(' • Claude Code: open this folder; approve project MCP if prompted (`claude mcp reset-project-choices` to reset).');
480
464
  console.log(' • Cursor: open this folder, restart Cursor or reload MCP, then confirm server under Settings → MCP.');
481
465
  console.log(' • Run `config` again anytime to change values (same files are merged/overwritten for `enfyra`).');
482
- if (!email || !password) {
483
- console.log('\nWarning: ENFYRA_EMAIL or ENFYRA_PASSWORD is empty — tools may not authenticate until set.');
466
+ if (!apiToken) {
467
+ console.log('\nWarning: ENFYRA_API_TOKEN is empty — tools will not authenticate until set.');
484
468
  }
485
469
  }
@@ -377,7 +377,7 @@ return @DATA`,
377
377
  <h2 class="text-lg font-semibold">Cloud projects</h2>
378
378
 
379
379
  <PermissionGate :condition="canCreateCloudProject">
380
- <UButton icon="i-lucide-plus" label="Create instance" @click="openCreate = true" />
380
+ <UButton icon="i-lucide-plus" label="Create project" @click="openCreate = true" />
381
381
  </PermissionGate>
382
382
  </div>
383
383
 
@@ -647,6 +647,36 @@ create_extension({
647
647
  'Put page-level actions in useHeaderActionRegistry or useSubHeaderActionRegistry.',
648
648
  'Page extensions should be full-bleed by default and responsive from the first version.',
649
649
  'The extension root is already inside eApp main; do not add root-level page padding.',
650
+ 'After saving, open eApp tabs should update through the server/eApp realtime reload contract; do not tell the user to refresh unless that contract is proven broken.',
651
+ ],
652
+ },
653
+ {
654
+ name: 'Debug menu or extension changes that do not appear in open eApp tabs',
655
+ code: `// Server side: menu_definition and extension_definition are runtime UI definitions.
656
+ // They must participate in partial reload, just like metadata/routes.
657
+ // Expected server contract:
658
+ // - cache orchestrator maps menu_definition -> menu reload
659
+ // - cache orchestrator maps extension_definition -> extension reload
660
+ // - successful writes emit $system:reload to the admin Socket.IO namespace
661
+
662
+ // eApp side expected listener behavior:
663
+ // if reload target is metadata/menu:
664
+ // await fetch menus
665
+ // rebuild menu registry with reset: true
666
+ // invalidate dynamic extension cache too, because route-to-extension mapping may change
667
+ // if reload target is extension/menu or extension/global:
668
+ // clear dynamic extension component/meta cache
669
+
670
+ // Verification pattern:
671
+ // 1. Save the menu or extension record.
672
+ // 2. Watch the open eApp tab for the $system:reload event.
673
+ // 3. Confirm sidebar/menu registry or extension component cache changed.
674
+ // 4. Only use manual reload endpoints or browser refresh after the natural event path is proven stale.`,
675
+ notes: [
676
+ 'Do not treat menu and extension writes as plain CRUD when debugging live admin UI.',
677
+ 'Check both halves: ASV/ESV emits the reload event, and eApp consumes it.',
678
+ 'Menu reload should also invalidate extension cache because menu records attach page extensions to routes.',
679
+ 'Manual reload is a fallback, not the default fix.',
650
680
  ],
651
681
  },
652
682
  {
@@ -40,7 +40,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
40
40
  '### Capability map (current Enfyra system)',
41
41
  '- **Schema/metadata:** `table_definition`, `relation_definition`, and schema tools manage tables, columns, relations, validation, and migrations. `column_definition` is internal/no-route; columns are created/updated through table schema operations.',
42
42
  '- **Dynamic REST API:** `route_definition`, `route_handler_definition`, `pre_hook_definition`, `post_hook_definition`, `route_permission_definition`, and `method_definition` define paths, methods, handlers, hooks, and permissions.',
43
- '- **Auth/OAuth/session:** `user_definition`, `role_definition`, `oauth_config_definition`, `oauth_account_definition`; `session_definition` is internal/no-route. OAuth is browser redirect only; MCP login is email/password. `user_definition` is the single source of truth for app users.',
43
+ '- **Auth/OAuth/session:** `user_definition`, `role_definition`, `api_token_definition`, `oauth_config_definition`, `oauth_account_definition`; `session_definition` and `api_token_definition` are internal/no-route. OAuth is browser redirect only. MCP uses `ENFYRA_API_TOKEN` through `/auth/token/exchange`; configure tokens from eApp `/me`. `user_definition` is the single source of truth for app users.',
44
44
  '- **Guards/permissions/validation:** `guard_definition`, `guard_rule_definition`, `field_permission_definition`, and `column_rule_definition` control route guards, field access, and request body validation.',
45
45
  '- **GraphQL:** `gql_definition` enables tables in GraphQL. GraphQL endpoint and schema share `ENFYRA_API_URL`; GraphQL requires Bearer auth.',
46
46
  '- **Files/storage/assets:** `file_definition`, `file_permission_definition`, `folder_definition`, `storage_config_definition` plus upload/assets routes and file helpers.',
@@ -64,7 +64,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
64
64
  '- OAuth starts on the same proxy prefix, e.g. **`GET /enfyra/auth/{provider}?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`**. `redirect` must be an absolute `http(s)` URL with the app origin. `cookieBridgePrefix` is the third app proxy prefix that forwards to the Enfyra API; Enfyra normalizes it, so `enfyra`, `/enfyra`, and `/enfyra/` all mean `/enfyra`. Use token-query callback handling only when the app intentionally manages tokens itself.',
65
65
  '- Socket.IO uses the app bridge too. Browser clients should connect to the gateway namespace with the Socket.IO transport path on the app origin, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, while Nuxt proxies `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. Do not connect browser code directly to the hidden backend Socket.IO endpoint.',
66
66
  '- If a project explicitly standardizes on `/api/**` instead of `/enfyra/**`, keep the same Cloud-style behavior under that prefix: proxy to the Enfyra API and avoid generated cookie-management routes unless the user asks for a custom auth boundary.',
67
- '- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server logs itself in with email/password against `{ENFYRA_API_URL}/auth/login`; for normal app work, `ENFYRA_API_URL` must still be the app proxy base such as `{{ nuxtApp }}/api`.',
67
+ '- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server exchanges `ENFYRA_API_TOKEN` against `{ENFYRA_API_URL}/auth/token/exchange`. For normal app work, `ENFYRA_API_URL` must still be the app proxy base such as `{{ nuxtApp }}/api`.',
68
68
  '',
69
69
  '### Routes vs tables (custom endpoints, handlers, hooks)',
70
70
  '- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
@@ -88,7 +88,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
88
88
  '- Common mapping: one owner record → `many-to-one`; one record has many children → define the child `many-to-one` and use inverse/read deep relation; peer/tag lists → `many-to-many`; one profile/settings row per parent → `one-to-one` when supported by the model.',
89
89
  '- If the user asks to add a foreign key field, interpret it as a relation request unless they explicitly say they need a plain scalar column. Do not create both a relation and a duplicate scalar FK column for the same concept.',
90
90
  '- **Never ask for or provide physical FK column names** when creating/updating relations. Do not include `fkCol`, `fkColumn`, `foreignKeyColumn`, `sourceColumn`, `targetColumn`, `junctionSourceColumn`, or `junctionTargetColumn` in create/update payloads unless you are only displaying existing metadata. Enfyra relation cascade derives physical FK/junction names from `propertyName` and table metadata, then hides FK columns from app form/schema definition.',
91
- '- For relation CRUD payloads, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`.',
91
+ '- For relation CRUD payloads and generated server logic, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}` or `"author": 1`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`, e.g. `{ "author": { "_eq": 1 } }` or `{ "author": { "id": { "_eq": 1 } } }`. Do not hardcode physical FK fields such as `userId` in handlers, hooks, flows, services, or extension-adjacent code unless you are deliberately querying raw SQL outside Enfyra metadata APIs.',
92
92
  '- **Realtime/chat unread modeling:** unread/read is per user and per message. Do not put `read` or `lastRead` on `chat_conversation` globally. Prefer a join table such as `chat_message_read` with relations `message`, `conversation`, `member`, boolean `isRead`, nullable `readAt`, unique `["message","member"]`, and indexes `["member","isRead","conversation"]` plus `["conversation","member","isRead"]`. The UI can render a dot by checking existence of unread rows instead of counting every unread message.',
93
93
  '- **Realtime/chat latest message modeling:** keep `chat_conversation.lastMessage` as a nullable many-to-one relation to `chat_message`. Do not duplicate latest message text/date onto `chat_conversation`. Load conversation lists with relation fields such as `lastMessage.id,lastMessage.text,lastMessage.createdAt`, update `lastMessage` after the message is persisted, and repair it in a `DELETE /chat_message` post-hook when deleting the current latest message.',
94
94
  '- **Chat deletion modeling:** user-level delete/leave should remove the user from `chat_conversation_member` or otherwise make membership inactive. Do not add duplicated `deleted_at` state to both conversation and membership unless the product explicitly needs restore/audit behavior. A DM deleted for both sides is a membership operation for both members; a group is physically deleted only when no memberships remain.',
@@ -122,7 +122,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
122
122
  '- If the **current request method** is listed in **publishedMethods** for that route, the server allows the call **without** a Bearer token (`RoleGuard`).',
123
123
  '- Otherwise the client must send an **Authorization** header with **Bearer** JWT from login. Then the user must satisfy **routePermissions** (unless root admin).',
124
124
  '- Cloud owner-scoped GET handlers must keep normal users filtered to their own `cloud_projects.owner`, but root admin operational views must bypass that owner filter with `@USER.isRootAdmin`. Apply this consistently to `cloud_projects`, `cloud_subscriptions`, `cloud_payment_orders`, `cloud_provisioning_history`, and `/cloud/projects` so admin data routes match admin summary APIs.',
125
- '- MCP tools that use `fetchAPI` authenticate with the configured admin credentials; explain to users that **direct HTTP** calls need a token unless the route/method is published.',
125
+ '- MCP tools that use `fetchAPI` authenticate with the configured `ENFYRA_API_TOKEN`. Explain to users that **direct HTTP** calls need a Bearer token unless the route/method is published.',
126
126
  '',
127
127
  '### Post-hooks (REST)',
128
128
  '- **post-hooks always run** after the handler, including when the handler or a pre-hook throws — then `@ERROR` / `$ctx.$error` is set and `@DATA` is null.',
@@ -163,7 +163,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
163
163
  '- Prefer `set(key, value, ttlMs)` with a TTL. `setNoExpire` is allowed, but persistent user-cache entries can still be evicted by the soft allocation limit.',
164
164
  '',
165
165
  '### OAuth login (browser / frontend — not the MCP `login` tool)',
166
- '- **MCP `login`** uses **email + password** `POST {base}/auth/login`. It cannot complete OAuth (no browser redirect).',
166
+ '- **MCP `login`** exchanges an API token through `POST {base}/auth/token/exchange`. It cannot complete OAuth (no browser redirect) and does not use email/password credentials.',
167
167
  '- **Supported providers (server):** `google`, `facebook`, `github` only.',
168
168
  '',
169
169
  '**Redirect URI must match everywhere (critical):**',
@@ -286,12 +286,13 @@ export function buildMcpServerInstructions(apiBaseUrl) {
286
286
  '- **Operational list data loading:** do not use arbitrary fixed limits such as `limit=50` as the whole data strategy for admin pages. Use pagination, expose result count when the API supports `meta=filterCount`, and add search/filter controls for natural lookup keys such as project id, name, and subdomain.',
287
287
  '- **ESV aggregate contract:** aggregate query must be an object keyed by a real field or relation, for example `aggregate: { id: { count: true }, status: { count: { _eq: "failed" } }, amount: { sum: true } }`. Results are returned in `response.meta.aggregate`. Time windows and cross-field conditions belong in top-level `filter`, not inside a field aggregate condition. Field aggregate conditions only support operators on that same field; relation aggregates use `countRecords`.',
288
288
  '- **Aggregate numeric rule:** `sum` and `avg` require a numeric field in ESV. Do not aggregate money stored as varchar/text. Use a numeric money field such as `amount_usd` with type `float`, `amount_cents`, or `amount` for revenue stats, or build a dedicated stats route that normalizes legacy values explicitly. If metadata says `float` but SQL aggregate still fails with `sum(character varying)`, the Enfyra Server physical schema is stale or missing the SQL float DDL mapping and must be redeployed/healed before relying on aggregate.',
289
- '- **Partial reload default:** ESV automatically triggers partial reloads for metadata, routes, extensions, flows, handlers, and related caches after successful writes. Do not reflexively call `/admin/reload`, `/admin/reload/metadata`, or `/admin/reload/routes` after each change. Verify naturally first; use manual reload only when verification shows stale behavior, a reload event failed, or a concrete error indicates the partial reload did not apply.',
289
+ '- **Partial reload default:** ESV/ASV automatically triggers partial reloads for metadata, routes, menus, extensions, flows, handlers, and related caches after successful writes. Do not reflexively call `/admin/reload`, `/admin/reload/metadata`, or `/admin/reload/routes` after each change. Verify naturally first; use manual reload only when verification shows stale behavior, a reload event failed, or a concrete error indicates the partial reload did not apply.',
290
+ '- **Menu/extension realtime reload contract:** `menu_definition` and `extension_definition` writes are runtime UI changes, not plain CRUD. The server cache orchestrator must emit `$system:reload` through the admin Socket.IO channel with identifiers that eApp handles; eApp must refetch menus/rebuild the menu registry for menu reloads and invalidate dynamic extension caches for extension reloads. Menu reloads can change route-to-extension mapping, so they should also invalidate extension cache. If an open admin tab does not reflect menu/extension changes, debug this two-sided reload contract before telling the user to refresh.',
290
291
  '- **Dashboard stats:** time range buttons must change the query filter and reload stats. Cloud dashboard should summarize flow execution errors from `flow_execution_definition` and billing stats from order/subscription records; successful/no-error flow runs do not need a standalone provisioning menu.',
291
292
  '- **Page layout default:** page extensions should render full-bleed inside the app shell by default. The extension root is already inside the eApp page `<main>`, so do not add root-level page padding such as `p-4 sm:p-6 xl:p-8`; use spacing between internal sections only. Do not wrap the entire page in a centered card/container unless explicitly requested. Use responsive grids/stacks from the first version so the page works on desktop, tablet, and mobile.',
292
293
  '- **PageHeader is mandatory for page extensions:** eApp already renders `CommonPageHeader` from `usePageHeaderRegistry()` in the app shell. Page extensions must call `const { registerPageHeader } = usePageHeaderRegistry()` and register app-level context such as `{ title, description, leadingIcon, gradient, variant }` instead of rendering their own top `<header>` inside extension content. Use `variant: "minimal"` for operational/admin detail pages unless the page intentionally needs a larger title strip.',
293
294
  '- **Do not misuse PageHeader stats:** `PageHeader.stats` renders prominent stat cards inside the shell header. Do not put normal operational KPIs, host capacity, billing totals, or detail metrics there by default; keep those as body cards/tables where the operator can scan them with the page content. Only use PageHeader stats for a deliberately compact overview page where the stats are truly header-level context.',
294
- '- **Page actions belong in registries:** Move page-level buttons into `useHeaderActionRegistry` or `useSubHeaderActionRegistry`; keep the extension body for operational content only. Sensitive registry actions must include a `permission` condition, for example `{ id: "create", label: "Create instance", permission: { and: [{ route: "/cloud_projects", methods: ["POST"] }] }, onClick }`.',
295
+ '- **Page actions belong in registries:** Move page-level buttons into `useHeaderActionRegistry` or `useSubHeaderActionRegistry`; keep the extension body for operational content only. Sensitive registry actions must include a `permission` condition, for example `{ id: "create", label: "Create project", permission: { and: [{ route: "/cloud_projects", methods: ["POST"] }] }, onClick }`.',
295
296
  '- **Extension navigation:** prefer `NuxtLink` or Nuxt UI components with `:to` for visible navigation links and drill-down cards/buttons. Use `navigateTo(...)` only for imperative navigation after submit, confirm, mutation, or another side effect.',
296
297
  '- **Extension runtime scope:** eApp exposes Vue APIs and injected Nuxt/Enfyra composables both to script global scope and Vue app `globalProperties`. Template expressions may call injected helpers directly, for example after a save handler can call `navigateTo("/data/cloud_projects")`, because Vue compiles template helpers to `_ctx.*`.',
297
298
  '- **Extension CSS affects shell utility ordering:** dynamic extension CSS is injected after the app shell CSS. Shell/page-header code must not put conflicting plain Tailwind utilities on the same element, such as `flex-col` plus `flex-row`, `items-start` plus `items-center`, or `text-left` plus `text-center`. Choose one mutually exclusive class per state; otherwise extension CSS can change which utility wins and shift shell layout.',
@@ -300,9 +301,12 @@ export function buildMcpServerInstructions(apiBaseUrl) {
300
301
  '- **PermissionGate is mandatory inside admin extensions:** every sensitive action button, form, mutation, destructive workflow, and data shortcut must be wrapped in `PermissionGate` or guarded with `usePermissions()` before rendering/enabling. Default gates: list/detail visibility needs `methods: ["GET"]`; create and custom flow-trigger routes usually need `methods: ["POST"]`; native record edits need `methods: ["PATCH"]`; native delete routes need `methods: ["DELETE"]`. Root admin still passes through normal permission helpers, but extension code must not rely on root-only assumptions.',
301
302
  '- **Extension permission UX:** if the current user can read a page but cannot perform an action, hide the action by default. If hiding would confuse the workflow, render a disabled state with a short reason. Never let the button render active and depend only on the server rejection; server permissions are the final boundary, not the UI contract.',
302
303
  '- **Cloud project admin operations:** use canonical `cloud_projects` table routes as the single source of truth. Admin manual create uses `POST /cloud_projects` with schema-safe fields `owner: { id }`, `plan: { id }`, `admin_email`, `admin_password`, `name`, `subdomain`, and `status: "creating"`. The UI must show tenant admin credential as an email/password pair and include a generate-password action before create; do not build an email-only credential form. Project detail is the place for destructive lifecycle actions. Disable uses `PATCH /cloud_projects/:id` with body `{ status: "disabled" }` and `confirm_tenant_id`/`confirm_hash` in query params. Enable uses `PATCH /cloud_projects/:id` with body `{ status: "running" }`. Delete uses `DELETE /cloud_projects/:id` with typed `confirm_tenant_id`, returned `requiredConfirmHash`, and the matching hash before triggering `cloud-delete-project`. Do not create separate one-off `/cloud/admin/projects/*` action routes for create/disable/enable/delete when the canonical table route can own the workflow.',
304
+ '- **Cloud admin terminology:** in Cloud admin UI, call physical tenant workloads "projects" everywhere. Do not label creation or details as "instance" unless the user explicitly asks for that word.',
305
+ '- **Cloud project create UI:** manual Cloud project creation should use `CommonDrawer`, not a wide modal. Let the operator search/select `user_definition` by email and select a plan with cards; do not expose duplicate free-text `user id` or `plan id` inputs when selectors exist. Prefer sending only the selected owner id, plan id, and required workflow fields such as `expiredAt` when the canonical handler can derive customer email, project name, subdomain, and password. Expiry selection should use quick presets plus a manual calendar (`UCalendar` when available, loaded through `install_package`/`getPackages` if an app package is needed).',
306
+ '- **Cloud host settings and creation UI:** host settings store only provider selection codes Enfyra controls, currently location and server type. Do not expose or save provider-derived RAM, disk, vCPU, or cost values by hand. Query the provider catalog route, show real package/location cards, support load-more/search when the list is long, and snapshot provider facts onto `cloud_servers` only during host creation.',
303
307
  '- **Flow schedule UI:** schedule trigger editors must keep the server contract as `triggerConfig.cron` and `triggerConfig.timezone`, but the UI should not be a bare cron field plus giant timezone dropdown. Provide common cadence presets, readable current-schedule summary, searchable access to all IANA timezones, suggested timezone shortcuts, and a custom cron escape hatch so operators can configure recurring checks without remembering cron syntax.',
304
- '- **Admin operation UI:** use eApp `CommonModal` for create, disable, delete, and multi-field confirmation workflows. `CommonModal` is the app-matched wrapper around Nuxt UI modal and follows the `UModal` contract: `v-model:open`, `#header`, `#body`, and `#footer`. Keep the page body as compact summary/actions; do not use raw `UModal`, custom fixed overlays, or large inline confirmation panels for these workflows.',
305
- '- **FormEditor is preferred for table-record forms:** when an extension creates or edits a concrete table record such as `cloud_host_settings`, `cloud_project_settings`, `cloud_servers`, or `cloud_projects`, use `FormEditor`/`FormEditorLazy` inside the modal/page instead of hand-building every input. Customize layout with `sections`, `includes`, and `field-map`; reserve custom inputs only for workflow-specific fields that are not table columns.',
308
+ '- **Admin operation UI:** use eApp `CommonModal` for compact create, disable, delete, and multi-field confirmation workflows. Use `CommonDrawer` for longer setup workflows such as Cloud host settings, host creation, project creation, and provider/package selection. Open the modal/drawer immediately on click, then render loading/error/content inside it; do not wait for async fetches to finish before showing the shell.',
309
+ '- **FormEditor is preferred for table-record forms:** when an extension creates or edits a concrete table record such as `cloud_host_settings`, `cloud_servers`, or `cloud_projects`, use `FormEditor`/`FormEditorLazy` inside the modal/page when the form is a direct table edit. Customize layout with `sections`, `includes`, and `field-map`; reserve custom inputs only for workflow-specific fields that are not table columns.',
306
310
  '- **Modal form layout:** inside `CommonModal`, stack each form control vertically with label text above a full-width input/control. Use a small grid/space stack such as `grid gap-4`, `p.text-sm.font-medium`, then `UInput class="mt-2 w-full"`. Do not place modal labels and inputs side by side unless the user explicitly asks for a dense horizontal form.',
307
311
  '- **Confirmation modal flow:** destructive/admin confirmation modals must read top-to-bottom as the operator workflow. For server-hash confirmations, render: tenant/id input first, then a full-width `Request hash` button, then a disabled hash input that is auto-filled by the server response, then the final destructive action in the footer. The final action stays disabled until the typed id matches and the server hash has been requested. Do not ask operators to manually type or edit the hash.',
308
312
  '- **Cloud host deletion:** do not delete `cloud_servers` directly from an extension or custom route. Root admin host deletion must be detail-only from `/cloud/hosts/:id`, call `POST /cloud/admin/hosts/delete`, require typed `confirm_host_id`, block when any project is running or attached, return a server-generated `requiredConfirmHash` for an empty host, and require the hash back via `confirmHash` query before triggering `cloud-delete-host`.',
@@ -343,7 +347,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
343
347
  '- **useMounted:** Mount state helper.',
344
348
  '',
345
349
  '#### Injected UI Components (auto-resolved):',
346
- '- **Common:** `EmptyState`, `LoadingState`, `ErrorState`, `PageHeader`, `FormCard`, `CommonModal`, `Modal`, `Drawer`, `BreadCrumbs`, `ListItem`, `LazyImage`, `GlobalConfirm`, `UploadModal`, `UploadModalLazy`, `AvatarInitials`, `BrandingHeader`, `SettingsCard`, `RouteLoading`',
350
+ '- **Common:** `EmptyState`, `LoadingState`, `ErrorState`, `PageHeader`, `FormCard`, `CommonModal`, `CommonDrawer`, `Modal`, `Drawer`, `BreadCrumbs`, `ListItem`, `LazyImage`, `GlobalConfirm`, `UploadModal`, `UploadModalLazy`, `AvatarInitials`, `BrandingHeader`, `SettingsCard`, `RouteLoading`',
347
351
  '- **Data Table:** `DataTable`, `DataTableLazy`, `ColumnSelector`',
348
352
  '- **Form:** `FormEditor`, `FormEditorLazy` (same API, lazy-loaded), `FilterEditor`, `FilterHistory`, `FieldSelector`',
349
353
  '- **File Manager:** `FileManager`, `FileView`, `FileGridCard`, `CreateFolderModal`',
@@ -370,6 +374,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
370
374
  '- **Search first with `search_npm`** if unsure of exact package name.',
371
375
  '- **Server** packages → available as `$ctx.$pkgs.packageName` in handlers/hooks.',
372
376
  '- **App** packages → available via `getPackages([\'dayjs\'])` in extensions (call in `onMounted`).',
377
+ '- **Extension package imports:** Do not write static imports like `import { CalendarDate } from "@internationalized/date"` inside `extension_definition.code`; the extension builder does not resolve app packages that way. Install the package as type `App`, then load it inside the extension with `const pkgs = await getPackages(["@internationalized/date"]); const { CalendarDate } = pkgs["@internationalized/date"];`.',
373
378
  '- **Do NOT use `create_record` on `package_definition` directly** — use `install_package` instead.',
374
379
  '',
375
380
  '#### Important patterns:',
@@ -377,7 +382,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
377
382
  '- **Header actions:** `useHeaderActionRegistry([{ id: \'refresh\', label: \'Refresh\', onClick: fn, color: \'primary\', icon: \'lucide:refresh\', order: 0 }])`',
378
383
  '- **Schema:** Call `fetchSchema()` first, then use `definition.value`, `editableFields.value`, `getField(\'fieldName\')`.',
379
384
  '- **Permissions:** Use `checkPermissionCondition({ or: [{ route: \'/posts\', methods: [\'GET\'] }] })` for complex rules. In templates, wrap sensitive controls with `<PermissionGate :condition="{ and: [{ route: \'/admin/action\', methods: [\'POST\'] }] }">...</PermissionGate>` instead of only disabling them visually.',
380
- '- **After create/update:** Tell user to refresh (F5). Changes may not appear until reload.',
385
+ '- **After menu/extension create/update:** open eApp tabs should update through the `$system:reload` contract. Do not tell the user to press F5 unless you have verified the natural reload event failed or the server/eApp version does not support menu/extension reload yet.',
381
386
  '',
382
387
  '#### Minimal example:',
383
388
  '`<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>`',
@@ -11,18 +11,17 @@ import { z } from 'zod';
11
11
 
12
12
  // Configuration
13
13
  const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
14
- const ENFYRA_EMAIL = process.env.ENFYRA_EMAIL || '';
15
- const ENFYRA_PASSWORD = process.env.ENFYRA_PASSWORD || '';
14
+ const ENFYRA_API_TOKEN = process.env.ENFYRA_API_TOKEN || '';
16
15
 
17
16
  // Import modules
18
- import { login, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
17
+ import { exchangeApiToken, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
19
18
  import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
20
19
  import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
21
20
  import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
22
21
  import { registerTableTools } from './lib/table-tools.js';
23
22
 
24
23
  // Initialize auth module
25
- initAuth(ENFYRA_API_URL, ENFYRA_EMAIL, ENFYRA_PASSWORD);
24
+ initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
26
25
 
27
26
  const CAPABILITY_AREAS = [
28
27
  {
@@ -37,8 +36,8 @@ const CAPABILITY_AREAS = [
37
36
  },
38
37
  {
39
38
  area: 'Auth, roles, sessions, OAuth',
40
- tables: ['user_definition', 'role_definition', 'session_definition', 'oauth_config_definition', 'oauth_account_definition'],
41
- workflow: 'Email/password login is /auth/login. OAuth is browser redirect based. session_definition is internal/no-route.',
39
+ tables: ['user_definition', 'role_definition', 'api_token_definition', 'session_definition', 'oauth_config_definition', 'oauth_account_definition'],
40
+ workflow: 'MCP auth exchanges ENFYRA_API_TOKEN through /auth/token/exchange. Configure an API token from eApp /me.',
42
41
  },
43
42
  {
44
43
  area: 'Guards and permissions',
@@ -1691,15 +1690,17 @@ server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
1691
1690
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1692
1691
  });
1693
1692
 
1694
- server.tool('login', 'Force login to Enfyra and get new tokens', {
1695
- email: z.string().email().optional().describe('Admin email'),
1696
- password: z.string().optional().describe('Password'),
1697
- }, async ({ email, password }) => {
1698
- const loginEmail = email || ENFYRA_EMAIL;
1699
- const loginPassword = password || ENFYRA_PASSWORD;
1700
- if (!loginEmail || !loginPassword) throw new Error('Email and password required');
1701
- await login(ENFYRA_API_URL, loginEmail, loginPassword);
1702
- return { content: [{ type: 'text', text: `Logged in successfully!\nToken expires: ${new Date(getTokenExpiry()).toISOString()}` }] };
1693
+ server.tool('login', 'Force authentication to Enfyra and get a new access token', {
1694
+ apiToken: z.string().optional().describe('API token; preferred for MCP and automation'),
1695
+ }, async ({ apiToken }) => {
1696
+ const token = apiToken || ENFYRA_API_TOKEN;
1697
+ if (token) {
1698
+ await exchangeApiToken(ENFYRA_API_URL, token);
1699
+ const expiry = getTokenExpiry();
1700
+ const expiryLabel = expiry === Infinity ? 'no expiration' : new Date(expiry).toISOString();
1701
+ return { content: [{ type: 'text', text: `Authenticated with API token.\nToken expires: ${expiryLabel}` }] };
1702
+ }
1703
+ throw new Error('ENFYRA_API_TOKEN required');
1703
1704
  });
1704
1705
 
1705
1706
  // ============================================================================
@@ -1831,7 +1832,7 @@ server.tool(
1831
1832
  'create_extension',
1832
1833
  [
1833
1834
  'Create an extension (Vue SFC page or widget). Code must be Vue SFC: <template>...</template> + <script setup>...</script> — NO imports, use globals (ref, useToast, useApi, UButton, etc).',
1834
- 'For type=page: create menu first (create_menu), get id, then pass menuId. For type=widget no menu needed. Server auto-compiles; tell user to refresh (F5) after create. See extension rules in MCP instructions.',
1835
+ 'For type=page: create menu first (create_menu), get id, then pass menuId. For type=widget no menu needed. Server auto-compiles and should emit realtime reload to open eApp tabs. See extension rules in MCP instructions.',
1835
1836
  ].join(' '),
1836
1837
  {
1837
1838
  name: z.string().describe('Extension name (unique)'),
@@ -1849,7 +1850,7 @@ server.tool(
1849
1850
  delete body.menuId;
1850
1851
  }
1851
1852
  const result = await fetchAPI(ENFYRA_API_URL, '/extension_definition', { method: 'POST', body: JSON.stringify(body) });
1852
- return { content: [{ type: 'text', text: `Extension created (ID: ${result.id}). Tell user to refresh (F5) to see it.\n${JSON.stringify(result, null, 2)}` }] };
1853
+ return { content: [{ type: 'text', text: `Extension created (ID: ${result.id}). Open eApp tabs should update through the realtime reload contract.\n${JSON.stringify(result, null, 2)}` }] };
1853
1854
  },
1854
1855
  );
1855
1856
 
@@ -1860,7 +1861,7 @@ server.tool(
1860
1861
  async function main() {
1861
1862
  console.error('Starting Enfyra MCP Server...');
1862
1863
  console.error(`API URL: ${ENFYRA_API_URL}`);
1863
- console.error(`Auth: ${ENFYRA_EMAIL ? `Configured (${ENFYRA_EMAIL})` : 'Not configured'}`);
1864
+ console.error(`Auth: ${ENFYRA_API_TOKEN ? 'API token configured' : 'Not configured'}`);
1864
1865
 
1865
1866
  const transport = new StdioServerTransport();
1866
1867
  await server.connect(transport);