@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 +10 -17
- package/package.json +1 -1
- package/src/index.mjs +1 -2
- package/src/lib/auth.js +32 -33
- package/src/lib/config-local.mjs +39 -55
- package/src/lib/mcp-examples.js +31 -1
- package/src/lib/mcp-instructions.js +16 -11
- package/src/mcp-server-entry.mjs +19 -18
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
|
|
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` / `-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
"
|
|
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
|
-
| `
|
|
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
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
|
-
-
|
|
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
|
|
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
|
|
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,
|
|
22
|
+
export function initAuth(apiUrl, apiToken = '') {
|
|
24
23
|
API_URL = apiUrl;
|
|
25
|
-
|
|
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
|
|
58
|
-
const authPassword = password || PASSWORD;
|
|
53
|
+
const token = apiToken || API_TOKEN;
|
|
59
54
|
|
|
60
|
-
if (!
|
|
61
|
-
throw new Error('
|
|
55
|
+
if (!token) {
|
|
56
|
+
throw new Error('API token required');
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
console.error('[Auth]
|
|
65
|
-
const response = await fetch(`${apiUrl}/auth/
|
|
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({
|
|
63
|
+
body: JSON.stringify({ apiToken: token }),
|
|
69
64
|
});
|
|
70
65
|
|
|
71
66
|
if (!response.ok) {
|
|
72
|
-
throw new Error(`
|
|
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 =
|
|
78
|
-
tokenExpiry = data.expTime;
|
|
72
|
+
refreshToken = null;
|
|
73
|
+
tokenExpiry = data.expTime == null ? Infinity : data.expTime;
|
|
79
74
|
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
138
|
+
return await refreshAccessToken(apiUrl);
|
|
140
139
|
}
|
|
141
|
-
|
|
140
|
+
throw new Error('ENFYRA_API_TOKEN required');
|
|
142
141
|
}
|
|
143
142
|
return accessToken;
|
|
144
143
|
}
|
package/src/lib/config-local.mjs
CHANGED
|
@@ -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
|
-
--
|
|
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 /
|
|
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 -
|
|
44
|
+
npx @enfyra/mcp-server config -a http://localhost:3000/api -t 'efy_pat_...'
|
|
46
45
|
npx @enfyra/mcp-server config --yes
|
|
47
|
-
|
|
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
|
-
|
|
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 === '--
|
|
82
|
-
else if (a === '--
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
`
|
|
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,
|
|
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,
|
|
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: '',
|
|
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 === '
|
|
198
|
-
if (key === 'ENFYRA_PASSWORD') values.password = value;
|
|
195
|
+
if (key === 'ENFYRA_API_TOKEN') values.apiToken = value;
|
|
199
196
|
}
|
|
200
|
-
if (values.apiUrl || 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.
|
|
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
|
-
|
|
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: '',
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
382
|
-
if (
|
|
383
|
-
const hint =
|
|
384
|
-
const line = (await q(`
|
|
385
|
-
|
|
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,
|
|
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
|
|
407
|
-
|
|
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
|
|
443
|
-
let password;
|
|
429
|
+
let apiToken;
|
|
444
430
|
if (usePrompt) {
|
|
445
431
|
const resolved = await promptConfig(opts, existing);
|
|
446
432
|
apiUrl = resolved.apiUrl;
|
|
447
|
-
|
|
448
|
-
password = resolved.password;
|
|
433
|
+
apiToken = resolved.apiToken;
|
|
449
434
|
} else {
|
|
450
435
|
const resolved = resolveNonInteractive(opts, existing);
|
|
451
436
|
apiUrl = resolved.apiUrl;
|
|
452
|
-
|
|
453
|
-
password = resolved.password;
|
|
437
|
+
apiToken = resolved.apiToken;
|
|
454
438
|
}
|
|
455
439
|
|
|
456
|
-
const serverEntry = buildServerEntry(apiUrl,
|
|
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,
|
|
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 (!
|
|
483
|
-
console.log('\nWarning:
|
|
466
|
+
if (!apiToken) {
|
|
467
|
+
console.log('\nWarning: ENFYRA_API_TOKEN is empty — tools will not authenticate until set.');
|
|
484
468
|
}
|
|
485
469
|
}
|
package/src/lib/mcp-examples.js
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
|
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
|
|
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`**
|
|
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
|
|
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. `
|
|
305
|
-
'- **FormEditor is preferred for table-record forms:** when an extension creates or edits a concrete table record such as `cloud_host_settings`, `
|
|
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:**
|
|
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>`',
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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,
|
|
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: '
|
|
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
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
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
|
|
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}).
|
|
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: ${
|
|
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);
|