@enfyra/mcp-server 0.0.101 → 0.0.103

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
@@ -186,6 +186,7 @@ The MCP server includes safety guards for LLM callers:
186
186
  - `validate_dynamic_script` checks handler, hook, flow, websocket, GraphQL, and bootstrap script source without saving.
187
187
  - `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
188
188
  - `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.
189
190
  - Relation tools reject physical FK/junction names.
190
191
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
191
192
  - 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.101",
3
+ "version": "0.0.103",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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`.',
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.',
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.',
@@ -493,9 +493,10 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
493
493
  const routeId = getId(route);
494
494
  const availableMethods = methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
495
495
  const publicMethods = methodNamesFromRecords(route?.publicMethods || [], methodIdNameMap);
496
+ const methodAvailable = availableMethods.includes(methodName);
496
497
  const routeNeedsUpdate = !!route && (
497
498
  route.isEnabled === false
498
- || !availableMethods.includes(methodName)
499
+ || !methodAvailable
499
500
  || (access === 'public' && !publicMethods.includes(methodName))
500
501
  || (access === 'private' && publicMethods.includes(methodName))
501
502
  || (opts.description !== undefined && route.description !== opts.description)
@@ -549,8 +550,8 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
549
550
  handlerId: getId(handler),
550
551
  reason: handlerNeedsOverwrite && !opts.overwrite ? 'Existing handler differs. Re-run with overwrite=true to update it.' : undefined,
551
552
  })
552
- : step(route ? 'pending' : 'waiting', 'save_handler', 'Create route handler', {
553
- reason: route ? undefined : 'Route must exist first.',
553
+ : step(route && methodAvailable ? 'pending' : 'waiting', 'save_handler', 'Create route handler', {
554
+ reason: !route ? 'Route must exist first.' : methodAvailable ? undefined : 'Route method must be available first.',
554
555
  }),
555
556
  ];
556
557
 
@@ -725,25 +726,40 @@ async function applyApiEndpointWorkflowStep(apiUrl, state, opts, stepId) {
725
726
  async function runApiEndpointWorkflow(apiUrl, opts) {
726
727
  let state = await resolveApiEndpointWorkflowState(apiUrl, opts);
727
728
  const operations = [];
729
+ let completedEphemeralStepId = null;
728
730
  if (opts.apply || opts.applyAll) {
729
731
  const maxSteps = opts.applyAll ? 10 : 1;
730
732
  for (let i = 0; i < maxSteps; i += 1) {
731
733
  if (state.blocked || !state.firstRunnable) break;
732
734
  const operation = await applyApiEndpointWorkflowStep(apiUrl, state, opts, opts.stepId);
733
735
  operations.push(operation);
736
+ if (state.firstRunnable.id === 'smoke_test') {
737
+ completedEphemeralStepId = 'smoke_test';
738
+ break;
739
+ }
734
740
  if (!opts.applyAll) break;
735
741
  state = await resolveApiEndpointWorkflowState(apiUrl, opts);
736
742
  }
737
743
  }
738
744
  const latestState = operations.length ? await resolveApiEndpointWorkflowState(apiUrl, opts) : state;
745
+ const latestSteps = completedEphemeralStepId
746
+ ? latestState.steps.map((item) => (
747
+ item.id === completedEphemeralStepId
748
+ ? { ...item, status: 'completed', result: 'passed' }
749
+ : item
750
+ ))
751
+ : latestState.steps;
752
+ const nextSteps = completedEphemeralStepId
753
+ ? latestState.nextSteps.filter((item) => item.stepId !== completedEphemeralStepId)
754
+ : latestState.nextSteps;
739
755
  return {
740
756
  action: operations.length ? 'api_endpoint_workflow_advanced' : 'api_endpoint_workflow_planned',
741
757
  endpoint: latestState.endpoint,
742
758
  scriptValidation: latestState.scriptValidation,
743
- steps: latestState.steps,
759
+ steps: latestSteps,
744
760
  operations,
745
- complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
746
- nextSteps: latestState.nextSteps,
761
+ complete: latestSteps.every((item) => ['completed', 'skipped'].includes(item.status)),
762
+ nextSteps,
747
763
  cleanupHints: latestState.endpoint.routeId
748
764
  ? [
749
765
  `Preview delete route handler with delete_record({ tableName: "enfyra_route_handler", id: ${JSON.stringify(latestState.endpoint.handlerId)} }) before cleanup.`,
@@ -1,5 +1,6 @@
1
1
  const RESPONSE_FORMAT = 'json+columnar-v1';
2
2
  const COLUMNAR_FORMAT = 'columnar-v1';
3
+ const COMPRESSION_STATS_FIELD = 'compressionStats';
3
4
 
4
5
  function isPlainObject(value) {
5
6
  if (!value || typeof value !== 'object') return false;
@@ -50,19 +51,75 @@ function toColumnar(value, seen = new WeakSet()) {
50
51
  return output;
51
52
  }
52
53
 
54
+ function safeJsonStringify(value) {
55
+ const seen = new WeakSet();
56
+ return JSON.stringify(value, (_key, entry) => {
57
+ if (!entry || typeof entry !== 'object') return entry;
58
+ if (seen.has(entry)) return '[Circular]';
59
+ seen.add(entry);
60
+ return entry;
61
+ });
62
+ }
63
+
64
+ function estimateTokens(jsonText) {
65
+ if (!jsonText) return 0;
66
+ return Math.ceil(jsonText.length / 4);
67
+ }
68
+
69
+ function buildCompressionStats(originalPayload, compactPayload) {
70
+ const originalTokens = estimateTokens(safeJsonStringify(originalPayload));
71
+ const compactTokens = estimateTokens(safeJsonStringify(compactPayload));
72
+ const savedTokens = originalTokens - compactTokens;
73
+ const savedPercent = originalTokens > 0
74
+ ? Number(((savedTokens / originalTokens) * 100).toFixed(2))
75
+ : 0;
76
+ return {
77
+ originalTokens,
78
+ compactTokens,
79
+ savedTokens,
80
+ savedPercent,
81
+ };
82
+ }
83
+
84
+ function attachCompressionStats(originalPayload, formattedPayload) {
85
+ if (
86
+ isPlainObject(formattedPayload)
87
+ && formattedPayload.responseFormat === RESPONSE_FORMAT
88
+ && formattedPayload[COMPRESSION_STATS_FIELD]
89
+ ) {
90
+ return formattedPayload;
91
+ }
92
+ if (!isPlainObject(formattedPayload)) {
93
+ const compactPayload = {
94
+ responseFormat: RESPONSE_FORMAT,
95
+ value: formattedPayload,
96
+ };
97
+ return {
98
+ ...compactPayload,
99
+ [COMPRESSION_STATS_FIELD]: buildCompressionStats(originalPayload, compactPayload),
100
+ };
101
+ }
102
+ return {
103
+ ...formattedPayload,
104
+ [COMPRESSION_STATS_FIELD]: buildCompressionStats(originalPayload, formattedPayload),
105
+ };
106
+ }
107
+
53
108
  export function formatJsonPayload(payload) {
54
109
  const formatted = toColumnar(payload);
55
110
  if (!isPlainObject(formatted)) {
56
- return {
111
+ return attachCompressionStats(payload, {
57
112
  responseFormat: RESPONSE_FORMAT,
58
113
  value: formatted,
59
- };
114
+ });
60
115
  }
61
- if (formatted.responseFormat === RESPONSE_FORMAT) return formatted;
62
- return {
116
+ if (formatted.responseFormat === RESPONSE_FORMAT) {
117
+ return attachCompressionStats(payload, formatted);
118
+ }
119
+ return attachCompressionStats(payload, {
63
120
  responseFormat: RESPONSE_FORMAT,
64
121
  ...formatted,
65
- };
122
+ });
66
123
  }
67
124
 
68
125
  export function jsonContent(payload, { pretty = false } = {}) {