@enfyra/mcp-server 0.0.109 → 0.0.110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.109",
3
+ "version": "0.0.110",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lib/auth.js CHANGED
@@ -8,13 +8,25 @@ let accessToken = null;
8
8
  let refreshToken = null;
9
9
  let tokenExpiry = null; // expTime từ server (milliseconds)
10
10
  let isRefreshing = false;
11
+ let exchangePromise = null;
11
12
 
12
13
  // Config
13
14
  let API_URL = 'http://localhost:3000/api';
14
15
  let API_TOKEN = '';
15
16
 
16
- // Refresh buffer: refresh token 1 minute before expiry
17
- const TOKEN_REFRESH_BUFFER = 60000;
17
+ const TOKEN_REFRESH_BUFFER = 20000;
18
+
19
+ function normalizeExpiry(expTime) {
20
+ if (expTime == null) return Infinity;
21
+ if (typeof expTime === 'number') return expTime < 1_000_000_000_000 ? expTime * 1000 : expTime;
22
+ if (typeof expTime === 'string' && expTime.trim()) {
23
+ const numeric = Number(expTime);
24
+ if (Number.isFinite(numeric)) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
25
+ const parsed = Date.parse(expTime);
26
+ if (Number.isFinite(parsed)) return parsed;
27
+ }
28
+ return Infinity;
29
+ }
18
30
 
19
31
  /**
20
32
  * Initialize auth module with config
@@ -25,7 +37,7 @@ export function initAuth(apiUrl, apiToken = '') {
25
37
  }
26
38
 
27
39
  /**
28
- * Check if token needs refresh (expires within 1 minute)
40
+ * Check if token needs refresh (expires within the refresh buffer)
29
41
  */
30
42
  export function needsRefresh() {
31
43
  if (tokenExpiry === Infinity) return false;
@@ -60,27 +72,37 @@ export async function exchangeApiToken(url, apiToken) {
60
72
  throw new Error('API token required');
61
73
  }
62
74
 
63
- console.error('[Auth] Exchanging API token...');
64
- const response = await fetch(`${apiUrl}/auth/token/exchange`, {
65
- method: 'POST',
66
- headers: { 'Content-Type': 'application/json' },
67
- body: JSON.stringify({ apiToken: token }),
68
- });
75
+ if (exchangePromise) return exchangePromise;
69
76
 
70
- if (!response.ok) {
71
- throw new Error(`API token exchange failed: ${await response.text()}`);
72
- }
77
+ exchangePromise = (async () => {
78
+ console.error('[Auth] Exchanging API token...');
79
+ const response = await fetch(`${apiUrl}/auth/token/exchange`, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ apiToken: token }),
83
+ });
73
84
 
74
- const data = await response.json();
75
- accessToken = data.accessToken || data.access_token;
76
- refreshToken = null;
77
- tokenExpiry = data.expTime == null ? Infinity : data.expTime;
85
+ if (!response.ok) {
86
+ throw new Error(`API token exchange failed: ${await response.text()}`);
87
+ }
78
88
 
79
- const expiryLabel = tokenExpiry === Infinity
80
- ? 'no expiration'
81
- : new Date(tokenExpiry).toISOString();
82
- console.error(`[Auth] API token exchanged, access token expires at ${expiryLabel}`);
83
- return accessToken;
89
+ const data = await response.json();
90
+ accessToken = data.accessToken || data.access_token;
91
+ refreshToken = null;
92
+ tokenExpiry = normalizeExpiry(data.expTime ?? data.exp_time ?? data.expiresAt ?? data.expires_at);
93
+
94
+ const expiryLabel = tokenExpiry === Infinity
95
+ ? 'no expiration'
96
+ : new Date(tokenExpiry).toISOString();
97
+ console.error(`[Auth] API token exchanged, access token expires at ${expiryLabel}`);
98
+ return accessToken;
99
+ })();
100
+
101
+ try {
102
+ return await exchangePromise;
103
+ } finally {
104
+ exchangePromise = null;
105
+ }
84
106
  }
85
107
 
86
108
  /**
@@ -119,7 +141,7 @@ export async function refreshAccessToken(url) {
119
141
  const data = await response.json();
120
142
  accessToken = data.accessToken || data.access_token;
121
143
  refreshToken = data.refreshToken || data.refresh_token;
122
- tokenExpiry = data.expTime;
144
+ tokenExpiry = normalizeExpiry(data.expTime ?? data.exp_time ?? data.expiresAt ?? data.expires_at);
123
145
 
124
146
  console.error(`[Auth] Token refreshed, expires at ${new Date(tokenExpiry).toISOString()}`);
125
147
  return accessToken;
@@ -1539,12 +1539,15 @@ ensure_page_extension({
1539
1539
  'Use enfyra_menu.label, not title.',
1540
1540
  'Sensitive admin menus should include a permission condition at creation time.',
1541
1541
  'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1542
+ 'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI.',
1542
1543
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1543
1544
  'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
1544
1545
  'Do not put ordinary KPI cards in PageHeader.stats; render metrics in the extension body.',
1545
1546
  'Put page-level actions in useHeaderActionRegistry or useSubHeaderActionRegistry, destructure register first, then call it with one action or an array.',
1546
1547
  'Page extensions should be full-bleed by default and responsive from the first version.',
1547
1548
  'The extension root is already inside Enfyra admin page main; do not add root-level page padding.',
1549
+ 'Use theme tokens/classes for panels, rows, badges, borders, and text. Pair border/divide utilities with border-default or divide-[var(--border-default)] so light and dark themes stay consistent.',
1550
+ 'Keep list selection local and fetch detail rows only; do not refetch the whole list after a row click unless the list data changed.',
1548
1551
  'Page extension paths are admin app UI routes. Do not verify them with test_rest_endpoint against ENFYRA_API_URL unless inspect_route shows an API route with the same path.',
1549
1552
  'After saving, open Enfyra admin tabs should update through the server/Enfyra admin UI realtime reload contract; do not tell the user to refresh unless that contract is proven broken.',
1550
1553
  ],
@@ -27,7 +27,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
27
27
  '- For a quick target/base sanity check, call `get_enfyra_api_context`; do not call broad discovery just to confirm which instance this MCP is connected to.',
28
28
  '- Discover before deciding. For architecture/capability questions call `discover_enfyra_system`; for DB/pk/runtime/cache context call `discover_runtime_context`; for filters/deep/sort/relation query shape call `discover_query_capabilities`. Run broad discovery tools sequentially, not in parallel.',
29
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
- '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category.',
30
+ '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category. Before extension UI work, call `get_extension_theme_contract` and follow the app-shell/theme/security contract.',
31
31
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
32
  '- With non-root API tokens, call `get_permission_profile` before relying on admin helper tools or when debugging 403s. MCP admin helpers require ordinary route permissions for static admin routes such as `/admin/script/validate`, `/admin/test/run`, `/admin/flow/trigger/:id`, and `/admin/reload/*`.',
33
33
  '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `enable_route`, `disable_route`, `delete_route`, `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, `choose_flow_step_tool`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
@@ -286,6 +286,40 @@ function jsonText(payload) {
286
286
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
287
287
  }
288
288
 
289
+ function getExtensionThemeContract() {
290
+ return {
291
+ action: 'extension_theme_contract',
292
+ useBefore: [
293
+ 'Call this before writing or reviewing Enfyra admin page, widget, or global extension UI.',
294
+ 'Then call validate_extension_code or an ensure_*_extension tool before saving.',
295
+ ],
296
+ layout: [
297
+ 'The extension is already mounted inside the Enfyra app shell. Do not add a duplicate page header, centered page wrapper, or root-level page padding.',
298
+ 'Page extensions should be full-bleed, responsive, and split large operations into focused pages or UTabs.',
299
+ 'Use usePageHeaderRegistry for the shell title and useHeaderActionRegistry/useSubHeaderActionRegistry for page actions.',
300
+ ],
301
+ theme: [
302
+ 'Use eApp theme tokens/classes, not hardcoded light or dark colors.',
303
+ 'Use bg-default, bg-muted, text-highlighted, text-muted, border-default when available.',
304
+ 'When using arbitrary CSS vars, prefer var(--surface-default), var(--surface-muted), var(--border-default), var(--text-primary), var(--text-secondary), and var(--text-tertiary).',
305
+ 'Never use bare border/divide-y for panels or rows: pair them with border-default or divide-[var(--border-default)]. Avoid border-black, black, slate-only, gray-only, and dark-only palettes.',
306
+ 'Status colors must remain readable in both themes; warning badges need high contrast text and a visible but not harsh border/background.',
307
+ ],
308
+ interaction: [
309
+ 'Every mutating button needs pending/disabled state, success/error feedback, and must close or update its modal when the operation completes.',
310
+ 'Do not refetch broad lists after selecting one row. Keep local selection state and fetch only the detail or mutation result needed.',
311
+ 'Use bounded pagination for operational lists. Do not replace pagination with arbitrary fixed caps such as 30 or 50.',
312
+ 'Customer-facing toasts must describe the operation. Do not surface raw job ids, flow ids, or worker ids.',
313
+ ],
314
+ security: [
315
+ 'Decide route permission, owner scope, and field exposure before writing UI or backend logic.',
316
+ 'UI checks are only guidance; handlers/hooks must independently enforce owner/root-admin authorization.',
317
+ 'Use the most specific business route or MCP tool. Do not write directly to raw tables when a domain route exists.',
318
+ ],
319
+ compactExample: '<template><section class="min-h-full w-full space-y-4"><div class="rounded-lg border border-default bg-default"><div class="border-b border-default px-4 py-3"><h2 class="text-base font-semibold text-highlighted">Title</h2><p class="text-sm text-muted">Short operational context.</p></div><div class="divide-y divide-[var(--border-default)]"><button class="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/60"><span class="text-sm font-medium text-highlighted">Row</span><span class="text-sm text-muted">Open</span></button></div></div></section></template>',
320
+ };
321
+ }
322
+
289
323
  function parseJsonObjectArg(name, value, fallback = {}) {
290
324
  if (value === undefined || value === null || value === '') return fallback;
291
325
  const parsed = typeof value === 'string' ? JSON.parse(value) : value;
@@ -979,6 +1013,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
979
1013
  [
980
1014
  'Validate Enfyra admin extension code before saving it to enfyra_extension.',
981
1015
  'Use this for Vue SFC page/widget/global extension code. It calls /enfyra_extension/preview and does not save anything.',
1016
+ 'Call get_extension_theme_contract first when generating or reviewing UI.',
982
1017
  ].join(' '),
983
1018
  {
984
1019
  code: z.string().describe('Vue SFC or compiled extension bundle code.'),
@@ -990,6 +1025,13 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
990
1025
  }),
991
1026
  );
992
1027
 
1028
+ server.tool(
1029
+ 'get_extension_theme_contract',
1030
+ 'Return the concise Enfyra admin extension UI/theme/security contract. Call before writing or reviewing extension UI.',
1031
+ {},
1032
+ async () => jsonText(getExtensionThemeContract()),
1033
+ );
1034
+
993
1035
  server.tool(
994
1036
  'set_table_graphql',
995
1037
  'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
@@ -1824,7 +1866,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1824
1866
 
1825
1867
  server.tool(
1826
1868
  'ensure_page_extension',
1827
- 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save.',
1869
+ 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save. Call get_extension_theme_contract first for UI work.',
1828
1870
  {
1829
1871
  name: z.string().describe('Extension unique name.'),
1830
1872
  code: z.string().describe('Vue SFC extension code.'),
@@ -1841,7 +1883,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1841
1883
 
1842
1884
  server.tool(
1843
1885
  'ensure_global_extension',
1844
- 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling.',
1886
+ 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.',
1845
1887
  {
1846
1888
  name: z.string().describe('Extension unique name.'),
1847
1889
  code: z.string().describe('Vue SFC extension code.'),
@@ -1857,7 +1899,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1857
1899
 
1858
1900
  server.tool(
1859
1901
  'ensure_widget_extension',
1860
- 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling.',
1902
+ 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.',
1861
1903
  {
1862
1904
  name: z.string().describe('Extension unique name.'),
1863
1905
  code: z.string().describe('Vue SFC extension code.'),