@enfyra/mcp-server 0.0.103 → 0.0.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -169,6 +169,7 @@ For normal apps and demos, enter the app/admin URL such as `http://localhost:300
169
169
  Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate implementation patterns. It returns focused examples for:
170
170
 
171
171
  - SSR app auth and proxy setup
172
+ - OAuth provider setup
172
173
  - schema, columns, relations, indexes, and validation
173
174
  - query filters, sorting, fields, deep relations, and aggregates
174
175
  - handlers, hooks, permissions, and RLS
@@ -186,7 +187,7 @@ The MCP server includes safety guards for LLM callers:
186
187
  - `validate_dynamic_script` checks handler, hook, flow, websocket, GraphQL, and bootstrap script source without saving.
187
188
  - `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
188
189
  - `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
189
- - JSON responses include `compressionStats` with estimated token savings for the columnar response format.
190
+ - JSON responses include `compressionStats` with estimated token savings. Arrays of objects are converted to columnar form only when the compact shape is smaller than raw JSON.
190
191
  - Relation tools reject physical FK/junction names.
191
192
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
192
193
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -457,6 +457,84 @@ window.location.href = url.toString()`,
457
457
  },
458
458
  ],
459
459
  },
460
+ 'oauth-setup': {
461
+ title: 'OAuth provider setup',
462
+ useWhen: 'Use when configuring Google or another OAuth provider for an Enfyra-backed app.',
463
+ examples: [
464
+ {
465
+ name: 'Google OAuth setup workflow',
466
+ code: `// 1. Ask for the public app/admin URL, not the API URL.
467
+ // Example input from the user:
468
+ const appUrl = "https://demo.enfyra.io"
469
+
470
+ // 2. Derive the Enfyra API base and provider callback.
471
+ const apiBase = appUrl.replace(/\\/$/, "") + "/api"
472
+ const googleCallbackUrl = apiBase + "/auth/google/callback"
473
+
474
+ // 3. Tell the user to paste this exact value into Google Cloud Console:
475
+ // APIs & Services -> Credentials -> OAuth 2.0 Client -> Authorized redirect URIs
476
+ // https://demo.enfyra.io/api/auth/google/callback
477
+
478
+ // 4. After the user provides Google client id/secret, save Enfyra config:
479
+ create_record({
480
+ tableName: "enfyra_oauth_config",
481
+ body: JSON.stringify({
482
+ provider: "google",
483
+ clientId: "<google-client-id>",
484
+ clientSecret: "<google-client-secret>",
485
+ redirectUri: googleCallbackUrl,
486
+ isEnabled: true
487
+ })
488
+ })`,
489
+ notes: [
490
+ 'Ask for the app/admin URL such as https://demo.enfyra.io; derive the API base by appending /api.',
491
+ 'The provider callback is {appUrl}/api/auth/{provider}/callback and must exactly match the Authorized redirect URI in Google Cloud Console.',
492
+ 'Do not ask the user to choose or type the callback URL manually once the app URL is known; compute it and show the exact value to paste.',
493
+ 'The OAuth callback is the Enfyra provider callback, not the final app page.',
494
+ 'When starting OAuth from a browser app, use the same-origin proxy route with redirect and cookieBridgePrefix as shown in ssr-app-auth examples.',
495
+ ],
496
+ },
497
+ {
498
+ name: 'Browser OAuth start URL after setup',
499
+ code: `const returnUrl = new URL("/dashboard", window.location.origin)
500
+ const oauthUrl = new URL("/enfyra/auth/google", window.location.origin)
501
+ oauthUrl.searchParams.set("redirect", returnUrl.toString())
502
+ oauthUrl.searchParams.set("cookieBridgePrefix", "/enfyra")
503
+ window.location.href = oauthUrl.toString()`,
504
+ notes: [
505
+ 'This is the browser start URL through the app proxy; it is different from the Google Authorized redirect URI.',
506
+ 'After Enfyra finishes the Google callback, it bridges cookies through /enfyra/auth/set-cookies and returns to the absolute redirect URL.',
507
+ 'After return, call /enfyra/me to load the user.',
508
+ ],
509
+ },
510
+ {
511
+ name: 'Update an existing Google OAuth config',
512
+ code: `const existing = await query_table({
513
+ tableName: "enfyra_oauth_config",
514
+ filter: JSON.stringify({ provider: { _eq: "google" } }),
515
+ fields: ["id", "provider", "redirectUri", "isEnabled"],
516
+ limit: 1
517
+ })
518
+
519
+ // If a row exists, update it instead of creating a duplicate.
520
+ update_record({
521
+ tableName: "enfyra_oauth_config",
522
+ id: "<existing-config-id>",
523
+ body: JSON.stringify({
524
+ clientId: "<google-client-id>",
525
+ clientSecret: "<google-client-secret>",
526
+ redirectUri: "https://demo.enfyra.io/api/auth/google/callback",
527
+ isEnabled: true
528
+ })
529
+ })`,
530
+ notes: [
531
+ 'Inspect first so setup is idempotent.',
532
+ 'Use the current system table name enfyra_oauth_config.',
533
+ 'Never expose the client secret back in app code or documentation.',
534
+ ],
535
+ },
536
+ ],
537
+ },
460
538
  'schema-relations': {
461
539
  title: 'Tables, columns, relations, cascade, and indexes',
462
540
  useWhen: 'Use when creating or changing persisted data models.',
@@ -35,7 +35,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
35
35
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
36
36
  '',
37
37
  '### Core Contracts',
38
- '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Any array of objects is encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }`; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings from the compact response; use it only when the user asks about savings.',
38
+ '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Large arrays of objects may be encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }` only when that is smaller than raw JSON; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings and includes whether compression was applied; use it only when the user asks about savings.',
39
39
  '- `query_table` and `get_all_routes` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
40
40
  '- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
41
41
  '- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
@@ -51,7 +51,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
51
51
  '',
52
52
  '### App Connection Defaults',
53
53
  '- Generated Nuxt/Next/SSR apps should use a same-origin proxy such as `/enfyra/**` to the Enfyra API. Browser code calls `/enfyra/login`, `/enfyra/me`, `/enfyra/logout`, and `/enfyra/<table>`; it should not store JWTs.',
54
- '- OAuth starts through the same proxy prefix with `redirect=<absoluteReturnUrl>` and `cookieBridgePrefix=/enfyra`. OAuth setup details live in `get_enfyra_examples({ category: "ssr-app-auth" })`.',
54
+ '- OAuth starts through the same proxy prefix with `redirect=<absoluteReturnUrl>` and `cookieBridgePrefix=/enfyra`. Provider setup details live in `get_enfyra_examples({ category: "oauth-setup" })`.',
55
55
  '- Socket.IO browser clients connect to the gateway namespace, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, while the app proxies `/socket.io/**` to Enfyra `/ws/socket.io/**`.',
56
56
  '',
57
57
  '### Dynamic Script Surface',
@@ -66,60 +66,73 @@ function estimateTokens(jsonText) {
66
66
  return Math.ceil(jsonText.length / 4);
67
67
  }
68
68
 
69
- function buildCompressionStats(originalPayload, compactPayload) {
69
+ function buildCompressionStats(originalPayload, candidatePayload, selectedPayload, applied) {
70
70
  const originalTokens = estimateTokens(safeJsonStringify(originalPayload));
71
- const compactTokens = estimateTokens(safeJsonStringify(compactPayload));
72
- const savedTokens = originalTokens - compactTokens;
71
+ const candidateTokens = estimateTokens(safeJsonStringify(candidatePayload));
72
+ const responseTokens = estimateTokens(safeJsonStringify(selectedPayload));
73
+ const candidateSavedTokens = originalTokens - candidateTokens;
74
+ const candidateSavedPercent = originalTokens > 0
75
+ ? Number(((candidateSavedTokens / originalTokens) * 100).toFixed(2))
76
+ : 0;
77
+ const savedTokens = originalTokens - responseTokens;
73
78
  const savedPercent = originalTokens > 0
74
79
  ? Number(((savedTokens / originalTokens) * 100).toFixed(2))
75
80
  : 0;
76
81
  return {
77
82
  originalTokens,
78
- compactTokens,
83
+ compactTokens: responseTokens,
79
84
  savedTokens,
80
85
  savedPercent,
86
+ applied,
87
+ candidateCompactTokens: candidateTokens,
88
+ candidateSavedTokens,
89
+ candidateSavedPercent,
81
90
  };
82
91
  }
83
92
 
84
- function attachCompressionStats(originalPayload, formattedPayload) {
93
+ function attachCompressionStats(originalPayload, candidatePayload, selectedPayload, applied) {
85
94
  if (
86
- isPlainObject(formattedPayload)
87
- && formattedPayload.responseFormat === RESPONSE_FORMAT
88
- && formattedPayload[COMPRESSION_STATS_FIELD]
95
+ isPlainObject(selectedPayload)
96
+ && selectedPayload.responseFormat === RESPONSE_FORMAT
97
+ && selectedPayload[COMPRESSION_STATS_FIELD]
89
98
  ) {
90
- return formattedPayload;
99
+ return selectedPayload;
91
100
  }
92
- if (!isPlainObject(formattedPayload)) {
93
- const compactPayload = {
94
- responseFormat: RESPONSE_FORMAT,
95
- value: formattedPayload,
96
- };
101
+ return {
102
+ ...selectedPayload,
103
+ [COMPRESSION_STATS_FIELD]: buildCompressionStats(originalPayload, candidatePayload, selectedPayload, applied),
104
+ };
105
+ }
106
+
107
+ function wrapPayload(payload) {
108
+ if (!isPlainObject(payload)) {
97
109
  return {
98
- ...compactPayload,
99
- [COMPRESSION_STATS_FIELD]: buildCompressionStats(originalPayload, compactPayload),
110
+ responseFormat: RESPONSE_FORMAT,
111
+ value: payload,
100
112
  };
101
113
  }
102
114
  return {
103
- ...formattedPayload,
104
- [COMPRESSION_STATS_FIELD]: buildCompressionStats(originalPayload, formattedPayload),
115
+ responseFormat: RESPONSE_FORMAT,
116
+ ...payload,
105
117
  };
106
118
  }
107
119
 
108
120
  export function formatJsonPayload(payload) {
109
- const formatted = toColumnar(payload);
110
- if (!isPlainObject(formatted)) {
111
- return attachCompressionStats(payload, {
112
- responseFormat: RESPONSE_FORMAT,
113
- value: formatted,
114
- });
115
- }
116
- if (formatted.responseFormat === RESPONSE_FORMAT) {
117
- return attachCompressionStats(payload, formatted);
121
+ if (
122
+ isPlainObject(payload)
123
+ && payload.responseFormat === RESPONSE_FORMAT
124
+ && payload[COMPRESSION_STATS_FIELD]
125
+ ) {
126
+ return payload;
118
127
  }
119
- return attachCompressionStats(payload, {
120
- responseFormat: RESPONSE_FORMAT,
121
- ...formatted,
122
- });
128
+
129
+ const originalPayload = wrapPayload(payload);
130
+ const columnarPayload = wrapPayload(toColumnar(payload));
131
+ const originalTokens = estimateTokens(safeJsonStringify(originalPayload));
132
+ const candidateTokens = estimateTokens(safeJsonStringify(columnarPayload));
133
+ const shouldApplyColumnar = candidateTokens < originalTokens;
134
+ const selectedPayload = shouldApplyColumnar ? columnarPayload : originalPayload;
135
+ return attachCompressionStats(originalPayload, columnarPayload, selectedPayload, shouldApplyColumnar);
123
136
  }
124
137
 
125
138
  export function jsonContent(payload, { pretty = false } = {}) {