@enfyra/mcp-server 0.0.95 → 0.0.97

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.95",
3
+ "version": "0.0.97",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,6 +47,47 @@ export default nextConfig`,
47
47
  'If you add Next middleware/proxy for auth gating, server-side checks may call the Enfyra API origin directly while forwarding the incoming Cookie header.',
48
48
  ],
49
49
  },
50
+ {
51
+ name: 'Angular dev proxy for REST and Socket.IO',
52
+ code: `// src/proxy.conf.json
53
+ {
54
+ "/enfyra/**": {
55
+ "target": "https://demo.enfyra.io/api",
56
+ "secure": true,
57
+ "changeOrigin": true,
58
+ "pathRewrite": {
59
+ "^/enfyra": ""
60
+ }
61
+ },
62
+ "/socket.io/**": {
63
+ "target": "https://demo.enfyra.io/api/ws",
64
+ "secure": true,
65
+ "changeOrigin": true,
66
+ "ws": true
67
+ }
68
+ }
69
+
70
+ // angular.json
71
+ {
72
+ "projects": {
73
+ "app": {
74
+ "architect": {
75
+ "serve": {
76
+ "options": {
77
+ "proxyConfig": "src/proxy.conf.json"
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }`,
84
+ notes: [
85
+ 'Browser code still calls /enfyra/login, /enfyra/me, /enfyra/logout, and /enfyra/<table>.',
86
+ 'The /enfyra proxy strips the prefix before forwarding to the Enfyra API origin.',
87
+ 'The /socket.io proxy forwards to the Enfyra app bridge /ws/socket.io while keeping the browser transport path as /socket.io.',
88
+ 'Restart ng serve after changing proxy.conf.json.',
89
+ ],
90
+ },
50
91
  {
51
92
  name: 'Password login and current user fetch',
52
93
  code: `await fetch("/enfyra/login", {
@@ -153,6 +194,85 @@ onUnmounted(() => {
153
194
  'Disconnect the singleton socket when the current user/session clears.',
154
195
  ],
155
196
  },
197
+ {
198
+ name: 'Angular HttpClient auth service and route guard',
199
+ code: `// app.config.ts
200
+ import { ApplicationConfig, inject } from "@angular/core"
201
+ import { provideRouter, CanActivateFn, Router } from "@angular/router"
202
+ import { HttpInterceptorFn, provideHttpClient, withInterceptors } from "@angular/common/http"
203
+ import { catchError, map, of } from "rxjs"
204
+
205
+ import { routes } from "./app.routes"
206
+ import { EnfyraAuthService } from "./enfyra-auth.service"
207
+
208
+ export const enfyraCredentialsInterceptor: HttpInterceptorFn = (req, next) => {
209
+ if (!req.url.startsWith("/enfyra/")) return next(req)
210
+ return next(req.clone({ withCredentials: true }))
211
+ }
212
+
213
+ export const requireUserGuard: CanActivateFn = () => {
214
+ const auth = inject(EnfyraAuthService)
215
+ const router = inject(Router)
216
+
217
+ return auth.loadMe().pipe(
218
+ map(user => user ? true : router.createUrlTree(["/login"])),
219
+ catchError(() => of(router.createUrlTree(["/login"])))
220
+ )
221
+ }
222
+
223
+ export const appConfig: ApplicationConfig = {
224
+ providers: [
225
+ provideHttpClient(withInterceptors([enfyraCredentialsInterceptor])),
226
+ provideRouter(routes)
227
+ ]
228
+ }
229
+
230
+ // enfyra-auth.service.ts
231
+ import { Injectable, signal } from "@angular/core"
232
+ import { HttpClient } from "@angular/common/http"
233
+ import { Observable, tap } from "rxjs"
234
+
235
+ type EnfyraUser = { id: string | number; email?: string }
236
+
237
+ @Injectable({ providedIn: "root" })
238
+ export class EnfyraAuthService {
239
+ readonly user = signal<EnfyraUser | null>(null)
240
+
241
+ constructor(private readonly http: HttpClient) {}
242
+
243
+ login(email: string, password: string): Observable<unknown> {
244
+ return this.http.post("/enfyra/login", { email, password, remember: true }).pipe(
245
+ tap(() => this.loadMe().subscribe())
246
+ )
247
+ }
248
+
249
+ loadMe(): Observable<EnfyraUser | null> {
250
+ return this.http.get<EnfyraUser | null>("/enfyra/me").pipe(
251
+ tap(user => this.user.set(user))
252
+ )
253
+ }
254
+
255
+ logout(): Observable<unknown> {
256
+ return this.http.post("/enfyra/logout", {}).pipe(
257
+ tap(() => this.user.set(null))
258
+ )
259
+ }
260
+
261
+ startGoogleOAuth(returnPath = "/") {
262
+ const redirect = new URL(returnPath, window.location.origin)
263
+ const url = new URL("/enfyra/auth/google", window.location.origin)
264
+ url.searchParams.set("redirect", redirect.toString())
265
+ url.searchParams.set("cookieBridgePrefix", "/enfyra")
266
+ window.location.href = url.toString()
267
+ }
268
+ }`,
269
+ notes: [
270
+ 'Use HttpClient with a credentials interceptor for /enfyra/* calls so cookies are sent consistently.',
271
+ 'The guard is only for user experience; Enfyra route permissions and server-side owner checks remain authoritative.',
272
+ 'Keep the current user in an Angular service or store; do not read JWTs from cookies or URLs.',
273
+ 'OAuth starts at the app proxy path and returns through the cookie bridge before the Angular route loads /enfyra/me.',
274
+ ],
275
+ },
156
276
  {
157
277
  name: 'Next client provider for authenticated realtime',
158
278
  code: `"use client"
@@ -243,6 +363,64 @@ export function useRealtime() {
243
363
  'Disconnect the singleton socket when the current user/session clears.',
244
364
  ],
245
365
  },
366
+ {
367
+ name: 'Angular singleton Socket.IO realtime service',
368
+ code: `// enfyra-realtime.service.ts
369
+ import { Injectable, computed, effect, signal } from "@angular/core"
370
+ import { io, Socket } from "socket.io-client"
371
+
372
+ import { EnfyraAuthService } from "./enfyra-auth.service"
373
+
374
+ @Injectable({ providedIn: "root" })
375
+ export class EnfyraRealtimeService {
376
+ private socket: Socket | null = null
377
+ private readonly connected = signal(false)
378
+ readonly isConnected = computed(() => this.connected())
379
+
380
+ constructor(private readonly auth: EnfyraAuthService) {
381
+ effect(() => {
382
+ const user = this.auth.user()
383
+ if (user) this.connect()
384
+ else this.disconnect()
385
+ })
386
+ }
387
+
388
+ connect() {
389
+ if (this.socket) return this.socket
390
+
391
+ this.socket = io("/chat", {
392
+ path: "/socket.io",
393
+ withCredentials: true,
394
+ reconnection: true,
395
+ reconnectionAttempts: Infinity,
396
+ reconnectionDelay: 2000,
397
+ reconnectionDelayMax: 30000
398
+ })
399
+
400
+ this.socket.on("connect", () => this.connected.set(true))
401
+ this.socket.on("disconnect", () => this.connected.set(false))
402
+ return this.socket
403
+ }
404
+
405
+ disconnect() {
406
+ this.socket?.disconnect()
407
+ this.socket = null
408
+ this.connected.set(false)
409
+ }
410
+
411
+ onMessage(handler: (event: unknown) => void) {
412
+ const activeSocket = this.connect()
413
+ activeSocket.on("chat:message", handler)
414
+ return () => activeSocket.off("chat:message", handler)
415
+ }
416
+ }`,
417
+ notes: [
418
+ 'Create one app-level Socket.IO connection after auth is known.',
419
+ 'Use the websocket namespace path from live metadata, such as /chat, and keep the transport path as /socket.io.',
420
+ 'Components subscribe with onMessage and call the returned cleanup function in ngOnDestroy.',
421
+ 'Do not create a new socket per routed component.',
422
+ ],
423
+ },
246
424
  {
247
425
  name: 'OAuth provider setup values',
248
426
  code: `// Enfyra OAuth config row, stored in enfyra_oauth_config.
@@ -33,9 +33,11 @@ export function buildMcpServerInstructions(apiBaseUrl) {
33
33
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
34
34
  '',
35
35
  '### Core Contracts',
36
+ '- 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`.',
36
37
  '- `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.',
37
38
  '- 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`.',
38
39
  '- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
40
+ '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
39
41
  '- Use `enfyra_user` as the user table. Model record links as real relations using relation `propertyName` values, not physical FK fields like `userId`, `conversationId`, `senderId`, or `memberId` in generated DB code.',
40
42
  '- Do not call internal/no-route system tables such as `enfyra_column` or `enfyra_session` through generic CRUD. Use table/column/relation tools and route-backed tables discovered from metadata.',
41
43
  '- Custom API paths use `create_route` without `mainTableId`; `create_table` is only for new persisted data.',
@@ -0,0 +1,117 @@
1
+ const RESPONSE_FORMAT = 'json+columnar-v1';
2
+ const COLUMNAR_FORMAT = 'columnar-v1';
3
+
4
+ function isPlainObject(value) {
5
+ if (!value || typeof value !== 'object') return false;
6
+ const proto = Object.getPrototypeOf(value);
7
+ return proto === Object.prototype || proto === null;
8
+ }
9
+
10
+ function valueForColumn(record, column) {
11
+ return Object.prototype.hasOwnProperty.call(record, column) ? record[column] : null;
12
+ }
13
+
14
+ function collectColumns(records) {
15
+ const columns = [];
16
+ const seen = new Set();
17
+ for (const record of records) {
18
+ for (const key of Object.keys(record)) {
19
+ if (seen.has(key)) continue;
20
+ seen.add(key);
21
+ columns.push(key);
22
+ }
23
+ }
24
+ return columns;
25
+ }
26
+
27
+ function toColumnar(value, seen = new WeakSet()) {
28
+ if (Array.isArray(value)) {
29
+ if (value.length > 0 && value.every(isPlainObject)) {
30
+ const columns = collectColumns(value);
31
+ return {
32
+ format: COLUMNAR_FORMAT,
33
+ columns,
34
+ rows: value.map((record) => columns.map((column) => toColumnar(valueForColumn(record, column), seen))),
35
+ rowCount: value.length,
36
+ };
37
+ }
38
+ return value.map((item) => toColumnar(item, seen));
39
+ }
40
+
41
+ if (!isPlainObject(value)) return value;
42
+ if (seen.has(value)) return '[Circular]';
43
+ seen.add(value);
44
+
45
+ const output = {};
46
+ for (const [key, entry] of Object.entries(value)) {
47
+ output[key] = toColumnar(entry, seen);
48
+ }
49
+ seen.delete(value);
50
+ return output;
51
+ }
52
+
53
+ export function formatJsonPayload(payload) {
54
+ const formatted = toColumnar(payload);
55
+ if (!isPlainObject(formatted)) {
56
+ return {
57
+ responseFormat: RESPONSE_FORMAT,
58
+ value: formatted,
59
+ };
60
+ }
61
+ if (formatted.responseFormat === RESPONSE_FORMAT) return formatted;
62
+ return {
63
+ responseFormat: RESPONSE_FORMAT,
64
+ ...formatted,
65
+ };
66
+ }
67
+
68
+ export function jsonContent(payload, { pretty = false } = {}) {
69
+ return {
70
+ content: [{
71
+ type: 'text',
72
+ text: JSON.stringify(formatJsonPayload(payload), null, pretty ? 2 : 0),
73
+ }],
74
+ };
75
+ }
76
+
77
+ function tryParseJson(text) {
78
+ if (typeof text !== 'string') return null;
79
+ const trimmed = text.trim();
80
+ if (!trimmed || !/^[\[{]/.test(trimmed)) return null;
81
+ try {
82
+ return JSON.parse(trimmed);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function formatContentItem(item) {
89
+ if (!item || item.type !== 'text') return item;
90
+ const parsed = tryParseJson(item.text);
91
+ if (!parsed) return item;
92
+ return {
93
+ ...item,
94
+ text: JSON.stringify(formatJsonPayload(parsed)),
95
+ };
96
+ }
97
+
98
+ export function formatToolResult(result) {
99
+ if (!result || !Array.isArray(result.content)) return result;
100
+ return {
101
+ ...result,
102
+ content: result.content.map(formatContentItem),
103
+ };
104
+ }
105
+
106
+ export function installColumnarToolFormatter(server) {
107
+ const registerTool = server.tool.bind(server);
108
+ server.tool = (name, description, schema, handler) => {
109
+ if (typeof handler !== 'function') {
110
+ return registerTool(name, description, schema, handler);
111
+ }
112
+ return registerTool(name, description, schema, async (...args) => {
113
+ const result = await handler(...args);
114
+ return formatToolResult(result);
115
+ });
116
+ };
117
+ }
@@ -23,6 +23,7 @@ import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
23
23
  import { registerTableTools } from './lib/table-tools.js';
24
24
  import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
25
25
  import { validateMainTableRoutePath } from './lib/route-guards.js';
26
+ import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
26
27
  import {
27
28
  findRoutePermission,
28
29
  mergeMethodNames,
@@ -407,10 +408,6 @@ function collectPartialErrors(results) {
407
408
  .map(([name, result]) => ({ name, error: result.error }));
408
409
  }
409
410
 
410
- function jsonContent(payload, { pretty = false } = {}) {
411
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, pretty ? 2 : 0) }] };
412
- }
413
-
414
411
  async function getMetadataTables() {
415
412
  const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
416
413
  return {
@@ -616,6 +613,7 @@ const server = new McpServer(
616
613
  instructions: buildMcpServerInstructions(ENFYRA_API_URL),
617
614
  },
618
615
  );
616
+ installColumnarToolFormatter(server);
619
617
 
620
618
  // ============================================================================
621
619
  // METADATA TOOLS