@enfyra/mcp-server 0.0.44 → 0.0.46

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,11 +1,14 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "src/index.mjs",
8
- "bin": "src/index.mjs",
8
+ "bin": {
9
+ "mcp-server": "src/index.mjs",
10
+ "enfyra-mcp-server": "src/index.mjs"
11
+ },
9
12
  "files": [
10
13
  "src",
11
14
  ".codex/skills"
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
  }
@@ -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):**',
@@ -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
  // ============================================================================
@@ -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);