@enfyra/mcp-server 0.0.44 → 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-instructions.js +5 -5
- package/src/mcp-server-entry.mjs +17 -16
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
|
}
|
|
@@ -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):**',
|
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
|
// ============================================================================
|
|
@@ -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);
|